#!/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). Steps can also be grouped into ``TaskLane``s and run through ``run_lanes``: two lanes each get their own worker thread, step list, progress bar, and log tail, drawn side by side (or stacked on a narrow terminal) so independent work — the audio.cpp build in one lane, model downloads in the other — runs simultaneously. Because ``redirect_stdout`` is process-global, the multi-lane view installs a thread-routing stdout/stderr proxy for the run's duration, so each lane's ``print()`` output lands in its own log. A single lane renders exactly like ``run_steps``. """ import contextlib import re import sys 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] @dataclass class TaskLane: """One column of a (possibly parallel) task view. A lane is a titled, ordered list of steps that run in its own worker thread. ``run_lanes`` draws a single lane full-width exactly like ``run_steps``, and splits the screen in half when two lanes are given so their steps (e.g. build and model download) run simultaneously. """ title: str steps: List[TaskStep] def _progress_match(text: str) -> Optional[Tuple[float, float, str]]: """Parse a progress line into ``(done, total, kind)``, else None. KIND is one of ``"bytes"`` (``AUDIOCPP_PROGRESS``), ``"percent"`` (``NN%``), or ``"count"`` (``[done/total]``), with the same guards the single-lane view applies (percents capped at 100, counts bounded by their total). """ match = _PROGRESS_BYTES.search(text) if match: return (int(match.group(1)), int(match.group(2)), "bytes") match = _PROGRESS_PERCENT.search(text) if match: percent = int(match.group(1)) if percent <= 100: return (percent, 100, "percent") match = _PROGRESS_COUNT.search(text) if match: done = int(match.group(1)) total = int(match.group(2)) if total > 0 and done <= total: return (done, total, "count") return None def _lane_step_mark(current: Optional[int], results: List[Optional[int]], cancelled_step: Optional[int], index: int, now: float, terminal: bool ) -> Tuple[str, str]: """The (mark, kind) for step INDEX of one lane; see TaskView._step_mark.""" if terminal: if index == cancelled_step: return "[x]", "warn" if results[index] == 0: return "[OK]", "ok" if results[index] is not None: return "[FAIL]", "err" return "[ ]", "dim" if index == current: frame = _SPINNER[int(now * 4) % len(_SPINNER)] return f"[{frame}]", "warn" if results[index] == 0: return "[OK]", "ok" if results[index] is not None: return "[FAIL]", "err" return "[ ]", "dim" def run_steps(scr, title: str, steps: List[TaskStep], wait_on_finish: bool = True) -> 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). With WAIT_ON_FINISH False the view returns to the caller as soon as the run reaches a terminal phase instead of waiting for a key press (used by the hub's start/stop actions, which land straight back on the menu). """ view = TaskView(scr, title, steps, wait_on_finish=wait_on_finish) 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 def run_lanes(scr, title: str, lanes: List[TaskLane]) -> int: """Run LANES inside the curses screen; return the first bad rc. Each lane is an ordered list of steps that run in its own worker thread. A single lane renders full-width exactly like ``run_steps``; two lanes are drawn side by side (or stacked on a narrow terminal) so their steps run simultaneously — the audio.cpp one-click setup builds the server in one lane while configuring and downloading models in the other. Empty lanes are dropped, so callers can build a lane list conditionally and always end up with "just build", "just download", or both. """ lanes = [lane for lane in lanes if lane.steps] if not lanes: return 0 if len(lanes) == 1: return run_steps(scr, title, lanes[0].steps) return LanesView(scr, title, lanes).run() 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, wait_on_finish: bool = True): import curses self.curses = curses self.scr = scr self.title = title self.steps = steps self.wait_on_finish = wait_on_finish 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_match(line) if match: done, total, kind = match self._progress = (done, total) self._progress_kind = kind if kind == "bytes": return # machine-readable progress is not part of the log # Percent/count lines stay in the log (the tail already # collapses rapid \r updates to the last full line). 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() if self.phase in _TERMINAL and not self.wait_on_finish: return self._result_rc() 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 ---------------------------------------------------- suffix = "" if not self.wait_on_finish else " — press any key to return" if self.phase == "done": footer = "completed" + suffix kind = "ok" elif self.phase == "cancelled": footer = "cancelled" + suffix kind = "warn" elif self.phase == "error": footer = "finished with errors" + suffix 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.""" return _lane_step_mark(self.current, self.results, self.cancelled_step, index, self._now(), self.phase in _TERMINAL) # --------------------------------------------------------------------------- # 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" def _rect_box(scr, curses, theme, x: int, y: int, w: int, h: int) -> None: """Draw a box around the rectangle ``(x, y, w, h)``.""" border = theme["border"] try: scr.addch(y, x, curses.ACS_ULCORNER, border) scr.addch(y, x + w - 1, curses.ACS_URCORNER, border) scr.addch(y + h - 1, x, curses.ACS_LLCORNER, border) scr.addch(y + h - 1, x + w - 1, curses.ACS_LRCORNER, border) scr.hline(y, x + 1, curses.ACS_HLINE, w - 2, border) scr.hline(y + h - 1, x + 1, curses.ACS_HLINE, w - 2, border) for yy in range(y + 1, y + h - 1): scr.addch(yy, x, curses.ACS_VLINE, border) scr.addch(yy, x + w - 1, curses.ACS_VLINE, border) except Exception: pass class _ThreadRouter: """A file-like object that routes writes to a per-thread writer. ``contextlib.redirect_stdout`` is process-global, so two lanes running in parallel would interleave their ``print()`` output. Instead, one router is installed on ``sys.stdout``/``sys.stderr`` for the whole view run and each lane's worker registers its ``_LineWriter`` while a step runs; writes from an unregistered thread fall through to the original stream. """ def __init__(self, fallback, registry: dict = None): self._fallback = fallback self._registry = registry if registry is not None else {} self._lock = threading.Lock() @contextlib.contextmanager def for_thread(self, writer): ident = threading.get_ident() with self._lock: self._registry[ident] = writer try: yield finally: with self._lock: self._registry.pop(ident, None) def write(self, text): writer = self._registry.get(threading.get_ident()) if writer is not None: return writer.write(text) return self._fallback.write(text) def flush(self): writer = self._registry.get(threading.get_ident()) if writer is not None: writer.flush() else: self._fallback.flush() def isatty(self) -> bool: return False class _LaneState: """Mutable state for one lane of a ``LanesView`` (see TaskView fields).""" def __init__(self, title: str, steps: List[TaskStep]): self.title = title self.steps = list(steps) self.queue: Queue = Queue() self.worker = None self.current: Optional[int] = None self.results: List[Optional[int]] = [None] * len(self.steps) self.log_tail: List[str] = [] self.progress: Optional[Tuple[float, float]] = None self.progress_kind = "" self.step_started: List[Optional[float]] = [None] * len(self.steps) self.cancelled_step: Optional[int] = None self.rc = 0 self.finished = False class LanesView: """A full-screen task view that runs two step lists in parallel. The two-lane counterpart of ``TaskView``: each lane gets its own worker thread, event queue, and state (step marks, progress bar, log tail), and the screen is split into two panes so both lanes' progress is visible at once. One shared cancel event stops both lanes. The run reaches its terminal phase only once every lane has finished; the returned rc is the first non-zero step rc across the lanes, in lane order. """ def __init__(self, scr, title: str, lanes: List[TaskLane], clock: Callable[[], float] = time.time): import curses self.curses = curses self.scr = scr self.title = title self.theme = tui._ensure_theme(curses) self._clock = clock self._lanes = [_LaneState(lane.title, lane.steps) for lane in lanes] self.phase = "running" # running | done | error | cancelled self.cancelled = False self.cancelling = False self.finished_at: Optional[float] = None self._cancel = threading.Event() # -- worker ------------------------------------------------------ def _lane_worker(self, lane: _LaneState, router: _ThreadRouter, cancel: threading.Event) -> None: first_failure = 0 def emit(line: str) -> None: lane.queue.put({"kind": "line", "text": line}) for index, step in enumerate(lane.steps): if cancel.is_set(): break lane.queue.put({"kind": "step_start", "index": index, "title": step.title}) try: with router.for_thread(_LineWriter(emit)): rc = step.work(emit, cancel) except Exception as exc: # noqa: BLE001 - reported to the view lane.queue.put({"kind": "line", "text": f"[ERROR] {exc}"}) rc = 1 if cancel.is_set(): lane.queue.put({"kind": "step_cancelled", "index": index}) break lane.queue.put({"kind": "step_done", "index": index, "rc": rc}) if rc != 0: first_failure = first_failure or rc lane.queue.put({"kind": "lane_finish", "rc": first_failure}) # -- event handling ---------------------------------------------- def _handle_lane_event(self, lane: _LaneState, event: dict) -> None: kind = event.get("kind") if kind == "step_start": lane.current = event["index"] lane.step_started[lane.current] = self._now() lane.progress = None lane.progress_kind = "" elif kind == "line": self._ingest_lane_line(lane, event.get("text") or "") elif kind == "step_done": lane.results[event["index"]] = event.get("rc") or 0 lane.current = None lane.progress = None lane.progress_kind = "" elif kind == "step_cancelled": lane.cancelled_step = event["index"] lane.current = None lane.progress = None lane.progress_kind = "" elif kind == "lane_finish": lane.rc = event.get("rc") or 0 lane.finished = True def _ingest_lane_line(self, lane: _LaneState, text: str) -> None: """Fold one output line into LANE's log tail and progress bar.""" line = text.rstrip("\r\n") if not line: return match = _progress_match(line) if match: done, total, kind = match lane.progress = (done, total) lane.progress_kind = kind if kind == "bytes": return lane.log_tail.append(line) if len(lane.log_tail) > _LOG_TAIL: del lane.log_tail[: len(lane.log_tail) - _LOG_TAIL] def _drain(self) -> None: for lane in self._lanes: while True: try: event = lane.queue.get_nowait() except Empty: break self._handle_lane_event(lane, event) if self.phase == "running" and all(lane.finished for lane in self._lanes): self._finish() def _finish(self) -> None: if self._cancel.is_set(): self.phase = "cancelled" self.cancelled = True else: self.phase = "done" for lane in self._lanes: if lane.rc: self.phase = "error" break self.finished_at = self._now() def _now(self) -> float: return self._clock() def _result_rc(self) -> int: """The exit code for the whole run (cancelled counts as failure).""" if self.cancelled: return 1 for lane in self._lanes: for rc in lane.results: if rc: return rc return 0 # -- main loop --------------------------------------------------- def run(self) -> int: scr = self.scr try: scr.timeout(_DRAW_TIMEOUT_MS) except Exception: pass registry = {} router_out = _ThreadRouter(sys.stdout, registry) router_err = _ThreadRouter(sys.stderr, registry) saved_out, saved_err = sys.stdout, sys.stderr sys.stdout, sys.stderr = router_out, router_err try: for lane in self._lanes: lane.worker = threading.Thread( target=self._lane_worker, args=(lane, router_out, self._cancel), daemon=True) lane.worker.start() 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() finally: sys.stdout, sys.stderr = saved_out, saved_err def _get_key(self) -> Optional[int]: try: key = self.scr.getch() except KeyboardInterrupt: return 3 if key == -1: return None return key def _prompt_cancel(self) -> bool: """Esc/q: confirm cancel, then wait for both workers 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() for lane in self._lanes: if lane.worker is not None: lane.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"]) terminal = self.phase in _TERMINAL inner_h = height - 3 if width >= 76: pane_w = (width - 3) // 2 rects = [(1, 1, pane_w, inner_h), (1 + pane_w + 1, 1, (width - 3) - pane_w, inner_h)] else: top_h = (inner_h - 1) // 2 rects = [(1, 1, width - 2, top_h), (1, 2 + top_h, width - 2, inner_h - top_h - 1)] for lane, (x, y, w, h) in zip(self._lanes, rects): self._draw_pane(curses, theme, x, y, w, h, lane, terminal) if self.phase == "done": footer, kind = "completed — press any key to return", "ok" elif self.phase == "cancelled": footer, kind = "cancelled — press any key to return", "warn" elif self.phase == "error": footer, kind = "finished with errors — press any key to return", "err" elif self.cancelling: footer, kind = "cancelling...", "warn" else: footer, kind = "Esc or q: cancel", "dim" _text(scr, theme, height - 2, 2, _fit(footer, width - 4), theme[kind]) scr.refresh() def _draw_pane(self, curses, theme, x: int, y: int, w: int, h: int, lane: _LaneState, terminal: bool) -> None: scr = self.scr _rect_box(scr, curses, theme, x, y, w, h) _text(scr, theme, y, x + 1, _fit(f" {lane.title} ", w - 2), theme["title"]) row = y + 1 for index, step in enumerate(lane.steps): mark, kind = _lane_step_mark(lane.current, lane.results, lane.cancelled_step, index, self._now(), terminal) label = _fit(f" {step.title} ", max(6, w - 8)) _text(scr, theme, row, x + 1, mark, theme.get(kind, theme["body"])) _text(scr, theme, row, x + 6, label, theme["body"]) if index == lane.current and not terminal: started = lane.step_started[index] or self._now() _text(scr, theme, row, x + 6 + len(label) + 1, f" {_format_elapsed(self._now() - started)}", theme["dim"]) row += 1 row += 1 if lane.progress is not None and not terminal: done, total = lane.progress bar_x = x + 10 bar_room = max(6, w - 12) filled = 0 if total: filled = round(bar_room * min(done, total) / total) filled = max(0, min(bar_room, filled)) _text(scr, theme, row, x + 1, "Progress".ljust(9), theme["dim"]) try: scr.addstr(row, bar_x, " " * filled, theme["bar"]) except Exception: pass _text(scr, theme, row, bar_x + bar_room + 1, _progress_label(lane.progress, lane.progress_kind), theme["accent"]) row += 1 for line in lane.log_tail[-_LOG_TAIL:]: if row >= y + h - 1: break _text(scr, theme, row, x + 1, _fit(line, w - 3), theme["dim"]) row += 1