diff options
| author | historia <historiavg@proton.me> | 2026-08-24 23:49:34 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 23:49:34 -0400 |
| commit | fe4b2b9eb7fb8aac81f65630720c9079d0a3121a (patch) | |
| tree | 9c5e5f56d0b931d25e6580f10d09453505eddc2f /app/ui | |
| parent | f4b1de303704e13818259d5057d176cd841b6ed8 (diff) | |
| download | tts-audiobook-generator-fe4b2b9eb7fb8aac81f65630720c9079d0a3121a.tar.gz | |
feat: user-friendly menu gating, clearer install/configure path for backends
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/hub.py | 165 | ||||
| -rw-r--r-- | app/ui/taskview.py | 537 | ||||
| -rw-r--r-- | app/ui/tui.py | 40 |
3 files changed, 692 insertions, 50 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py index d71fa4c..247dd49 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -10,8 +10,10 @@ The entire hub runs in one curses session, driven by a single ``tui.Wizard`` stack of screens (the ``_Hub`` class below). Every menu/action is a screen that returns the next screen, ``Wizard.BACK`` (Esc/q) to pop one screen, or None to quit. Backend setup wizards and the conversion run view run as -opaque leaf screens on this same session (console tails under -``tui.suspend``); a leaf screen finishes by returning ``Wizard.BACK``, so +opaque leaf screens on this same session (the wizards' long setup tails and +model downloads run inside the ``ui.taskview`` task view, and only the quick +uninstall/start/stop actions use ``tui.suspend``); a leaf screen finishes by +returning ``Wizard.BACK``, so the stack lands back on the menu that launched it. Esc therefore steps back exactly one screen everywhere — on the main menu (an empty stack) it quits. 'q' mirrors Esc on every screen that has no typed text. @@ -56,7 +58,7 @@ from converter.tts import ( BACKEND_QWEN, normalize_language, ) -from ui import runview, tui +from ui import runview, taskview, tui _CANCEL = object() # sentinel: a convert preflight confirm backed out @@ -74,6 +76,12 @@ def run() -> int: return 0 except KeyboardInterrupt: return 130 + finally: + # The curses session is over and the terminal is restored: surface + # anything setup steps queued for the console (e.g. a failed audio.cpp + # build's copy-pastable command and build log path). + for notice in common.drain_post_tui_notices(): + print(notice) return 0 @@ -134,11 +142,15 @@ class _Hub: def screen_configure(self): """One flat menu of backend setup/configure/cleanup actions. - Options are populated from the detected statuses: install (any - uninstalled backend), configure each installed backend, - download/delete audio.cpp models (when a server.json references - models on/off disk), and uninstall. Selecting one pushes the next - screen; Esc pops back to the main menu. + The audio.cpp "next step" — build its server (when a checkout has + no binary) or download its missing models (only once built, so + build > configure > download — Build and Download never appear + together) — heads the menu with a yellow ``[recommended]`` tag, + separated from the rest by a blank line. The remaining options are + populated from the detected statuses: configure each installed + backend, install (backends with nothing on disk), and uninstall. + Selecting one pushes the next screen; Esc pops back to the main + menu. """ while True: statuses = detect_all() @@ -146,25 +158,42 @@ class _Hub: installed = [info for info in REGISTRY if by_key.get(info.key) is not None and by_key[info.key].installed] - options = [(f"Configure {info.label}", ("configure", info.key)) - for info in installed] - if any(info.key not in by_key or not by_key[info.key].installed - for info in REGISTRY): - options.append(("Install Backend", "install")) audiocpp_status = by_key.get("audiocpp") missing = [] - if audiocpp_status is not None and audiocpp_status.installed: + needs_build = False + if audiocpp_status is not None: checkout = audiocpp_backend.find_local_checkout() - server_json = checkout / "server.json" if checkout else None - if server_json is not None and server_json.exists(): - missing = audiocpp_backend.missing_model_entries( - server_json) - if missing: + if checkout is not None: + built = audiocpp_backend.find_audiocpp_server_bin( + checkout) is not None + if not built: + needs_build = True + server_json = checkout / "server.json" + # Models can only be downloaded once the server binary + # exists (build > configure > download), so Build and + # Download never appear together. + if built and audiocpp_status.configured \ + and server_json.exists(): + missing = audiocpp_backend.missing_model_entries( + server_json) + + options = [] + if needs_build: + options.append(("Build audio.cpp server", "build_audiocpp", + ("[recommended]", "warn"))) + elif missing: options.append(("Download Missing Models (audio.cpp)", - "download_models")) - - if installed: + "download_models", + ("[recommended]", "warn"))) + if needs_build or missing: + options.append(tui.MENU_SEPARATOR) + + options += [(f"Configure {info.label}", ("configure", info.key)) + for info in installed] + if any(_installable(info, by_key) for info in REGISTRY): + options.append(("Install Backend", "install")) + if any(_uninstallable(info, by_key) for info in REGISTRY): options.append(("Uninstall Backend", "uninstall")) choice = tui.menu( @@ -183,6 +212,9 @@ class _Hub: if choice == "download_models": _download_models_action(self.stdscr) continue # an inline action: re-show this same menu + if choice == "build_audiocpp": + audiocpp_backend.build_screen(self.stdscr) + continue # an inline action: re-show this same menu _kind, key = choice info = get(key) if info is None: @@ -223,20 +255,22 @@ class _Hub: def _pick_backend(self, installed_only: bool): """Pick a backend for the Install/Uninstall actions. - With INSTALLED_ONLY False every backend is listed (the install - list); with it True only the currently-installed ones are (the - uninstall list). Returns a registry entry, or None to go back. + With INSTALLED_ONLY False every backend with nothing on disk yet is + listed (the install list — audio.cpp only without a checkout, since + a downloaded-but-unbuilt checkout is past install); with it True the + ones with something on disk to remove are (the uninstall list — + including a downloaded-but-unbuilt audio.cpp checkout, which + ``uninstall`` deletes whole). Returns a registry entry, or None to + go back. """ statuses = detect_all() by_key = {st.key: st for st in statuses} if installed_only: candidates = [info for info in REGISTRY - if by_key.get(info.key) is not None - and by_key[info.key].installed] + if _uninstallable(info, by_key)] else: candidates = [info for info in REGISTRY - if by_key.get(info.key) is None - or not by_key[info.key].installed] + if _installable(info, by_key)] if not candidates: tui.flash(self.stdscr, "No backends to list here.") return None @@ -384,15 +418,45 @@ class _Hub: return tui.Wizard.BACK +def _installable(info, by_key: dict) -> bool: + """True when INFO has nothing on disk yet — an install-entry candidate. + + audio.cpp is installable only without a checkout: a downloaded-but-unbuilt + checkout is already past the install step (its next action is the hub's + Build entry), so listing it under "Install Backend" would duplicate that + and suggest re-running setup from scratch. The other backends are + installable while not installed. + """ + if info.key == "audiocpp": + return audiocpp_backend.find_local_checkout() is None + status = by_key.get(info.key) + return status is None or not status.installed + + +def _uninstallable(info, by_key: dict) -> bool: + """True when INFO has something on disk that uninstall removes. + + audio.cpp's ``installed`` flag means *built*, so a downloaded-but-unbuilt + checkout would otherwise miss the Uninstall menu — but its checkout + (binary, models, server.json) lives on disk and ``uninstall()`` removes + it, so it counts too. The other backends' ``installed`` already covers + everything their uninstaller touches. + """ + if info.key == "audiocpp": + return audiocpp_backend.find_local_checkout() is not None + status = by_key.get(info.key) + return status is not None and status.installed + + def _download_models_action(stdscr) -> None: """Run the "Download Missing Models (audio.cpp)" action inside the TUI. - Computes the missing models; when they map to install commands it - suspends curses to stream the downloads, then flashes a result — instead - of silently returning to the main menu. When the checkout/server.json is - missing, nothing is missing, or the models do not map to an install - command, it flashes an explanatory notice (the latter explaining how to - install each model by hand). + Computes the missing models; when they map to install commands it runs + the downloads in the task view (with real byte progress and cancellation) + and flashes a result — instead of dropping to the console. When the + checkout/server.json is missing, nothing is missing, or the models do not + map to an install command, it flashes an explanatory notice (the latter + explaining how to install each model by hand). """ checkout = audiocpp_backend.find_local_checkout() if checkout is None: @@ -415,10 +479,16 @@ def _download_models_action(stdscr) -> None: tui.flash(stdscr, audiocpp_backend.hand_install_guidance( checkout, missing), "err") return - with tui.suspend(stdscr): - audiocpp_backend.install_models(checkout, guidance) - tui.flash(stdscr, "Model download finished. See the output above for " - "any warnings.", "ok") + + def run(emit, cancel): + audiocpp_backend.install_models(checkout, guidance, + emit=emit, cancel=cancel) + return 0 + + taskview.run_steps(stdscr, "Download models", + [taskview.TaskStep("Download missing models", run)]) + tui.flash(stdscr, "Model download finished. Any warnings were shown in " + "the log.", "ok") def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: @@ -428,10 +498,13 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: server this tool started (``status.managed``) — or remotely — a server found by probing its remote URL (``status.remote``); the text names which, e.g. "running [local]", "running [remote]", or - "running [local, remote]". Otherwise 'installed' (orange/warn) when the - backend is present on disk, or 'unavailable' (red/err); a backend that is - neither installed nor running is unusable, so its name is dimmed - (NAME_KIND). A multi-model backend (qwen) also names which models + "running [local, remote]". Otherwise a backend set up only part-way + (``status.partial``) shows that label verbatim (amber), e.g. audio.cpp's + "downloaded (not built)" or "built (not configured)"; 'installed' + (orange/warn) when the backend is present on disk; or 'unavailable' + (red/err). A backend that is neither installed nor running is unusable, + so its name is dimmed (NAME_KIND). A multi-model backend (qwen) also + names which models answered in parentheses, e.g. "running [local, remote] (Base, CustomVoice)". CURSES has no true orange, so the theme's yellow 'warn' is used; it renders amber/orange on most terminals. @@ -448,6 +521,12 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: if status.running_models: text += " (" + ", ".join(status.running_models) + ")" return (text, "ok", "body") + if status is not None and status.partial: + # Part-way set up (audio.cpp: "downloaded (not built)" / + # "built (not configured)"): amber text, name dimmed while the + # backend is still unusable. + name_kind = "dim" if not status.installed else "body" + return (status.partial, "warn", name_kind) if status is not None and status.installed: if status.models_missing and not status.running: return ("installed (models missing)", "warn", "body") diff --git a/app/ui/taskview.py b/app/ui/taskview.py new file mode 100644 index 0000000..c0977ff --- /dev/null +++ b/app/ui/taskview.py @@ -0,0 +1,537 @@ +#!/usr/bin/env python3 +"""A full-screen task runner for long setup steps that stay in the TUI. + +Long backend-setup steps (git clone, audiocpp_server build, model downloads, +pip installs, whisper transcription) used to run under ``tui.suspend``, which +dumped the user into plain console output. This widget keeps them inside the +hub's curses session: a worker thread runs an ordered list of ``TaskStep``s +while the main thread redraws a DOS-style frame showing each step's state +(pending / running with a spinner and elapsed clock / [OK] / [FAIL]), an +optional progress bar for the current step, and a dim scrolling log tail of +the step's output. + +Steps stream their output by calling ``emit(line)`` (or simply printing to +stdout/stderr, which the view captures). The view turns output into progress +three ways, best-effort: + + * ``AUDIOCPP_PROGRESS downloaded=N total=M`` (audio.cpp model downloads, + hidden from the log) — an exact bytes bar; + * ``NN%`` (git ``Receiving objects: 45%``, cmake/make ``[ 45%]``, tqdm) — + a percent bar; + * ``[done/total]`` (ninja build output) — a count bar. + +A ``threading.Event`` passed to every step is set when the user confirms +cancel (Esc/q); subprocess runners kill their child process group, and +in-process steps are expected to check it between units of work. When all +steps finish (or are cancelled) the view shows a summary and waits for a key +press, so a failure is never scrolled away. ``run_steps`` returns the first +non-zero step exit code (0 when every step succeeded). +""" + +import contextlib +import re +import threading +import time +from dataclasses import dataclass +from queue import Empty, Queue +from typing import Callable, List, Optional, Tuple + +from ui import tui + +# Redraw cadence for the timed getch (milliseconds). +_DRAW_TIMEOUT_MS = 250 + +# How many recent output lines the log tail keeps. +_LOG_TAIL = 10 + +# Terminal state: the run is over and the screen waits for a key. +_TERMINAL = ("done", "error", "cancelled") + +# Progress-line matchers, in order of precedence. +_PROGRESS_BYTES = re.compile(r"AUDIOCPP_PROGRESS downloaded=(\d+) total=(\d+)") +_PROGRESS_PERCENT = re.compile(r"(\d{1,3})%") +_PROGRESS_COUNT = re.compile(r"\[(\d+)/(\d+)\]") + +# A spinner frame set for the running step marker. +_SPINNER = ("|", "/", "-", "\\") + + +@dataclass +class TaskStep: + """One step of a task view run. + + WORK is ``work(emit, cancel) -> int``: it streams output lines through + EMIT and returns its exit code (0 = success). CANCEL is a + ``threading.Event`` the view sets when the user confirms cancel; WORK + should stop promptly and may return any code (the view reports the run + as "cancelled" regardless). + """ + title: str + work: Callable[[Callable[[str], None], threading.Event], int] + + +def run_steps(scr, title: str, steps: List[TaskStep]) -> int: + """Run STEPS in order inside the curses screen; return the first bad rc. + + Returns 0 when every step succeeded, otherwise the first non-zero exit + code (a cancelled run returns a non-zero code too). + """ + view = TaskView(scr, title, steps) + return view.run() + + +def run_steps_inline(steps: List[TaskStep], emit=None, cancel=None) -> int: + """Run STEPS in order without the curses view; return the first bad rc. + + The console/CLI counterpart of ``run_steps``: each step's work is called + directly (EMIT None keeps the current plain-console subprocess behavior), + and every step runs even when an earlier one failed — matching how the + wizards warn-and-continue today. + """ + first = 0 + for step in steps: + rc = step.work(emit, cancel) + if rc and not first: + first = rc + return first + + +class TaskView: + """Draws and drives one list of setup steps; see the module docstring.""" + + def __init__(self, scr, title: str, steps: List[TaskStep], + clock: Callable[[], float] = time.time): + import curses + self.curses = curses + self.scr = scr + self.title = title + self.steps = steps + self.theme = tui._ensure_theme(curses) + self._clock = clock + # -- state ----------------------------------------------------- + self.phase = "running" # running | done | error | cancelled + self.current: Optional[int] = None # index of the running step + self.results: List[Optional[int]] = [None] * len(steps) + self.cancelled_step: Optional[int] = None + self.log_tail: List[str] = [] + self._progress: Optional[Tuple[float, float]] = None # (done, total) + self._progress_kind = "" # "bytes" | "percent" | "count" | "" + self.step_started: List[Optional[float]] = [None] * len(steps) + self.finished_at: Optional[float] = None + self.cancelled = False + self.cancelling = False + # -- threads --------------------------------------------------- + self._queue: Queue = Queue() + self._cancel = threading.Event() + self._worker = threading.Thread(target=self._worker_main, daemon=True) + + # ------------------------------------------------------------------ + # Worker + # ------------------------------------------------------------------ + + def _worker_main(self) -> None: + first_failure = 0 + for index, step in enumerate(self.steps): + if self._cancel.is_set(): + break + self._queue.put({"kind": "step_start", "index": index, + "title": step.title}) + try: + with contextlib.redirect_stdout(_LineWriter(self._emit)), \ + contextlib.redirect_stderr(_LineWriter(self._emit)): + rc = step.work(self._emit, self._cancel) + except Exception as exc: # noqa: BLE001 - reported to the view + self._queue.put({"kind": "line", + "text": f"[ERROR] {exc}"}) + rc = 1 + if self._cancel.is_set(): + self._queue.put({"kind": "step_cancelled", "index": index}) + break + self._queue.put({"kind": "step_done", "index": index, "rc": rc}) + if rc != 0: + first_failure = first_failure or rc + # Keep going where the console path would only warn; the + # failing step stays marked [FAIL]. + if self._cancel.is_set(): + self._queue.put({"kind": "finish", "phase": "cancelled", + "rc": first_failure or 1}) + elif first_failure: + self._queue.put({"kind": "finish", "phase": "error", + "rc": first_failure}) + else: + self._queue.put({"kind": "finish", "phase": "done", "rc": 0}) + + def _emit(self, line: str) -> None: + """Forward one output line to the view queue (progress-aware).""" + self._queue.put({"kind": "line", "text": line}) + + # ------------------------------------------------------------------ + # Event handling + # ------------------------------------------------------------------ + + def handle_event(self, event: dict) -> None: + kind = event.get("kind") + if kind == "step_start": + self.current = event["index"] + self.step_started[self.current] = self._now() + self._progress = None + self._progress_kind = "" + elif kind == "line": + text = event.get("text") or "" + self._ingest_line(text) + elif kind == "step_done": + index = event["index"] + self.results[index] = event.get("rc") or 0 + self.current = None + self._progress = None + self._progress_kind = "" + elif kind == "step_cancelled": + self.cancelled_step = event["index"] + self.current = None + self._progress = None + self._progress_kind = "" + elif kind == "finish": + self.phase = event.get("phase") or "done" + self.cancelled = self.phase == "cancelled" + self.finished_at = self._now() + self.current = None + + def _ingest_line(self, text: str) -> None: + """Fold one output line into the log tail and progress bar.""" + line = text.rstrip("\r\n") + if not line: + return + match = _PROGRESS_BYTES.search(line) + if match: + total = int(match.group(2)) + done = int(match.group(1)) + self._progress = (done, total) + self._progress_kind = "bytes" + return # machine-readable progress is not part of the log + match = _PROGRESS_PERCENT.search(line) + if match: + percent = int(match.group(1)) + if percent <= 100: + self._progress = (percent, 100) + self._progress_kind = "percent" + # Fall through: keep the line in the log (the tail already + # collapses rapid \r updates to the last full line). + else: + match = _PROGRESS_COUNT.search(line) + if match: + done = int(match.group(1)) + total = int(match.group(2)) + if total > 0 and done <= total: + self._progress = (done, total) + self._progress_kind = "count" + self.log_tail.append(line) + if len(self.log_tail) > _LOG_TAIL: + del self.log_tail[: len(self.log_tail) - _LOG_TAIL] + + def _finish(self, phase: str) -> None: + self.phase = phase + if self.finished_at is None: + self.finished_at = self._now() + + def _now(self) -> float: + return self._clock() + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + + def run(self) -> int: + scr = self.scr + try: + scr.timeout(_DRAW_TIMEOUT_MS) + except Exception: + pass + self._worker.start() + first_failure = 0 + try: + while True: + self._drain() + self.render() + key = self._get_key() + if key is None: + continue + if self.phase in _TERMINAL: + return self._result_rc() + if key in (27, ord("q"), 3) and not self.cancelling: + if self._prompt_cancel(): + self._drain() + return self._result_rc() + finally: + self._cancel.set() + + def _result_rc(self) -> int: + """The exit code for the whole run (cancelled counts as failure).""" + if self.cancelled: + return 1 + return next((rc for rc in self.results if rc), 0) + + def _get_key(self) -> Optional[int]: + try: + key = self.scr.getch() + except KeyboardInterrupt: + return 3 + if key == -1: + return None + return key + + def _drain(self) -> None: + while True: + try: + event = self._queue.get_nowait() + except Empty: + return + self.handle_event(event) + + def _prompt_cancel(self) -> bool: + """Esc/q: confirm cancel, then wait for the worker to wind down.""" + self._blocking() + try: + answer = tui.confirm(self.scr, "Cancel this step?", default=False, + cancel_value=False) + finally: + self._nonblocking() + if not answer: + return False + self.cancelling = True + self._cancel.set() + self._worker.join(timeout=60) + return True + + def _blocking(self) -> None: + try: + self.scr.timeout(-1) + except Exception: + pass + + def _nonblocking(self) -> None: + try: + self.scr.timeout(_DRAW_TIMEOUT_MS) + except Exception: + pass + + # ------------------------------------------------------------------ + # Drawing + # ------------------------------------------------------------------ + + def render(self) -> None: + curses, theme = self.curses, self.theme + scr = self.scr + scr.erase() + height, width = scr.getmaxyx() + if height < 12 or width < 40: + _text(scr, theme, height // 2, 2, "Terminal too small", + curses.A_BOLD) + scr.refresh() + return + + _box(scr, curses, theme, height, width) + _text(scr, theme, 0, 2, _fit(f" {self.title} ", width - 4), + theme["title"]) + + inner_x = 2 + y = 2 + # -- step list ------------------------------------------------- + for index, step in enumerate(self.steps): + mark, kind = self._step_mark(index) + label = _fit(f" {step.title} ", max(8, width - inner_x - 14)) + _text(scr, theme, y, inner_x, mark, theme.get(kind, theme["body"])) + _text(scr, theme, y, inner_x + 5, label, theme["body"]) + if index == self.current and self.phase not in _TERMINAL: + started = self.step_started[index] or self._now() + _text(scr, theme, y, inner_x + 5 + len(label) + 1, + f" {_format_elapsed(self._now() - started)}", + theme["dim"]) + y += 1 + + y += 1 + _sep(scr, curses, theme, y, width) + y += 1 + + # -- progress bar ---------------------------------------------- + if self._progress is not None and self.phase not in _TERMINAL: + done, total = self._progress + bar_x = inner_x + 10 + bar_room = max(10, width - bar_x - 16) + filled = 0 + if total: + filled = round(bar_room * min(done, total) / total) + filled = max(0, min(bar_room, filled)) + _text(scr, theme, y, inner_x, "Progress".ljust(9), theme["dim"]) + try: + scr.addstr(y, bar_x, " " * filled, theme["bar"]) + except Exception: + pass + _text(scr, theme, y, bar_x + bar_room + 1, + _progress_label(self._progress, self._progress_kind), + theme["accent"]) + y += 1 + + # -- log tail -------------------------------------------------- + for line in self.log_tail[-_LOG_TAIL:]: + _text(scr, theme, y, inner_x, _fit(line, width - inner_x - 2), + theme["dim"]) + y += 1 + if y >= height - 3: + break + + # -- footer ---------------------------------------------------- + if self.phase == "done": + footer = "completed — press any key to return" + kind = "ok" + elif self.phase == "cancelled": + footer = "cancelled — press any key to return" + kind = "warn" + elif self.phase == "error": + footer = "finished with errors — press any key to return" + kind = "err" + elif self.cancelling: + footer = "cancelling..." + kind = "warn" + else: + footer = "Esc or q: cancel" + kind = "dim" + _text(scr, theme, height - 2, 2, _fit(footer, width - 4), + theme[kind]) + scr.refresh() + + def _step_mark(self, index: int) -> Tuple[str, str]: + """The (mark, kind) for step INDEX.""" + if self.phase in _TERMINAL: + if index == self.cancelled_step: + return "[x]", "warn" + if self.results[index] == 0: + return "[OK]", "ok" + if self.results[index] is not None: + return "[FAIL]", "err" + return "[ ]", "dim" + if index == self.current: + frame = _SPINNER[int(self._now() * 4) % len(_SPINNER)] + return f"[{frame} ]", "warn" + if self.results[index] == 0: + return "[OK]", "ok" + if self.results[index] is not None: + return "[FAIL]", "err" + return "[ ]", "dim" + + +# --------------------------------------------------------------------------- +# Small helpers (module-level for testability) +# --------------------------------------------------------------------------- + +class _LineWriter: + """A file-like object that forwards writes to a per-line callback. + + Handles carriage-return progress updates (git/tqdm) by treating ``\r`` + as a line terminator too, so the last full line always reflects the + latest progress. + """ + + def __init__(self, emit: Callable[[str], None]): + self._emit = emit + self._buffer = "" + + def write(self, text: str) -> int: + if not text: + return 0 + self._buffer += text + while True: + cut = _find_line_end(self._buffer) + if cut < 0: + break + line, self._buffer = self._buffer[:cut], self._buffer[cut + 1:] + if line: + self._emit(line) + return len(text) + + def flush(self) -> None: + if self._buffer: + self._emit(self._buffer) + self._buffer = "" + + def isatty(self) -> bool: + return False + + +def _find_line_end(text: str) -> int: + """Index of the earliest ``\n`` or ``\r`` in TEXT, else -1.""" + newline = text.find("\n") + carriage = text.find("\r") + if newline < 0: + return carriage + if carriage < 0: + return newline + return min(newline, carriage) + + +def _text(scr, theme, y, x, text, attr) -> None: + try: + scr.addstr(y, x, text, attr) + except Exception: + pass + + +def _box(scr, curses, theme, height, width) -> None: + border = theme["border"] + try: + scr.addch(0, 0, curses.ACS_ULCORNER, border) + scr.addch(0, width - 1, curses.ACS_URCORNER, border) + scr.addch(height - 1, 0, curses.ACS_LLCORNER, border) + scr.addch(height - 1, width - 1, curses.ACS_LRCORNER, border) + scr.hline(0, 1, curses.ACS_HLINE, width - 2, border) + scr.hline(height - 1, 1, curses.ACS_HLINE, width - 2, border) + for y in range(1, height - 1): + scr.addch(y, 0, curses.ACS_VLINE, border) + scr.addch(y, width - 1, curses.ACS_VLINE, border) + except Exception: + pass + + +def _sep(scr, curses, theme, y, width) -> None: + try: + scr.addch(y, 0, curses.ACS_LTEE, theme["border"]) + scr.addch(y, width - 1, curses.ACS_RTEE, theme["border"]) + scr.hline(y, 1, curses.ACS_HLINE, width - 2, theme["dim"]) + except Exception: + pass + + +def _fit(text: str, width: int) -> str: + if width < 1: + return "" + if len(text) <= width: + return text + return text[: max(0, width - 1)] + "~" + + +def _format_elapsed(seconds: float) -> str: + seconds = max(0, int(seconds)) + hours, remainder = divmod(seconds, 3600) + minutes, secs = divmod(remainder, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes}:{secs:02d}" + + +def _progress_label(progress: Tuple[float, float], kind: str) -> str: + done, total = progress + if kind == "bytes": + return f"{_fmt_bytes(done)} / {_fmt_bytes(total)}" + if kind == "count": + return f"{int(done)}/{int(total)}" + return f"{int(done)}%" + + +def _fmt_bytes(size: float) -> str: + value = float(size) + for unit in ("B", "KB", "MB", "GB"): + if value < 1024 or unit == "GB": + if unit == "B": + return f"{int(value)}{unit}" + return f"{value:.1f}{unit}" + value /= 1024 + return f"{value:.1f}GB" diff --git a/app/ui/tui.py b/app/ui/tui.py index 0391998..0d47863 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -141,6 +141,10 @@ def flash(scr, text: str, kind: str = "warn") -> None: # 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 @@ -661,6 +665,12 @@ def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0, 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. @@ -686,9 +696,12 @@ def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0, """ 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(options) - 1)) + cursor = max(0, min(default_index, len(entries) - 1)) while True: frame.rows = [] for line in help_lines or []: @@ -715,21 +728,34 @@ def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0, frame.theme.get(kind, frame.theme["body"]))], align="left") frame.mark("") - base = len(frame.rows) - for label, _ in options: - frame.mark(label, selectable=True, align="left") - frame.cursor = base + cursor + 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(options), wrap=True) + moved = frame.motion(key, cursor, len(entries), wrap=True) if moved is not None: cursor = moved elif key in (10, 13): - return options[cursor][1] + return entries[cursor][1] # --------------------------------------------------------------------------- |
