diff options
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/hub.py | 191 | ||||
| -rw-r--r-- | app/ui/runview.py | 55 | ||||
| -rw-r--r-- | app/ui/taskview.py | 85 | ||||
| -rw-r--r-- | app/ui/tui.py | 29 | ||||
| -rw-r--r-- | app/ui/viewkit.py | 86 |
5 files changed, 253 insertions, 193 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py index 8d7dc15..214a47e 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -69,6 +69,7 @@ from converter.clients import ( audiocpp_entry_voice_capability, audiocpp_family_narrates, audiocpp_family_voice_policy, + audiocpp_voice_for_run, normalize_language, ) from ui import runview, taskview, tui @@ -274,23 +275,27 @@ class _Hub: return tui.Wizard.BACK return screen - def _run_configure(self, info) -> None: - """Run one backend's dedicated configure screen on this session.""" + def _run_screen(self, info, runner) -> None: + """Run INFO's screen function (setup or configure) on this session. + + Shared wrapper for _run_setup/_run_configure: a WizardCancelled is + a normal exit, any other exception flashes instead of taking the + hub down. + """ try: - info.configure_screen(self.stdscr) + runner(self.stdscr) except tui.WizardCancelled: pass except Exception as exc: # noqa: BLE001 - keep the hub alive tui.flash(self.stdscr, str(exc), "err") + def _run_configure(self, info) -> None: + """Run one backend's dedicated configure screen on this session.""" + self._run_screen(info, info.configure_screen) + def _run_setup(self, info) -> None: """Run one backend's setup wizard on this session (no stack frame).""" - try: - info.setup_screen(self.stdscr) - except tui.WizardCancelled: - pass - except Exception as exc: # noqa: BLE001 - keep the hub alive - tui.flash(self.stdscr, str(exc), "err") + self._run_screen(info, info.setup_screen) def screen_install(self): """Pick a backend to install and run its setup inline. @@ -443,7 +448,12 @@ class _Hub: pass except Exception as exc: # noqa: BLE001 - keep the hub alive view._cancel.set() - view._worker.join(timeout=30) + worker = view._worker + if worker is not None and worker.is_alive(): + try: + worker.join(timeout=30) + except RuntimeError: + pass tui.flash(self.stdscr, f"The run view failed: {exc}", "err") finally: try: @@ -465,12 +475,14 @@ class _Hub: result = tui.form(self.stdscr, "Settings", fields, back_value=tui.Wizard.BACK) if not (result is tui.Wizard.BACK or result is None): - # Save pressed: apply as before, no prompt. + # Save pressed: apply, and on failure loop back into the + # form with the edits intact instead of discarding them. try: _apply_settings(result) + return tui.Wizard.BACK except ValueError as exc: tui.flash(self.stdscr, str(exc), "err") - return tui.Wizard.BACK + continue # q/Esc (or the Cancel button) left the form without saving: # with no edits there is nothing to keep, so go straight back; # otherwise ask whether the edits should be preserved. @@ -591,11 +603,15 @@ def _server_action_step(spec, action: str): def work(emit, cancel): inner = sys.stdout # the task view's line-writer, when run in TUI - with contextlib.redirect_stdout(logging_kit.TeeWriter(logf, inner)): - if action == "start": - ok = servers.start(spec, cancel=cancel) - else: - ok = servers.stop(spec.name) + try: + with contextlib.redirect_stdout( + logging_kit.TeeWriter(logf, inner)): + if action == "start": + ok = servers.start(spec, cancel=cancel) + else: + ok = servers.stop(spec.name) + finally: + logf.close() return 0 if ok else 1 return taskview.TaskStep(title, work), log_path @@ -779,7 +795,7 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: 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: + if status.models_missing: return ("installed (models missing)", "warn", "body") return ("installed", "ok", "body") return ("unavailable", "err", "dim") @@ -938,6 +954,39 @@ def _convert_form(stdscr) -> Optional[tuple]: return fields, builders, statuses +def _tui_confirm(stdscr) -> Callable: + """The overwrite-confirm callback the TUI pre-flight hands the converter. + + Asks with tui.confirm (the console input() would scribble over + curses); the cancel answer raises _BackToForm so the caller returns + to the Generate form. + """ + def confirm(message: str, default: bool) -> bool: + answer = tui.confirm(stdscr, message, default=default, + cancel_value=_CANCEL) + if answer is _CANCEL: + raise _BackToForm() + return answer + return confirm + + +def _check_preflight_plan(stdscr, book_files: list, planned: dict) -> bool: + """The shared nothing-to-convert flashes; True when there is a plan. + + PLANNED maps a run key (a model id, or "" for the single-model run) + to that run's plan; a run happens when any of them is non-empty. + """ + if not book_files: + tui.flash(stdscr, "No books to convert. Add a .txt, .pdf or .epub " + "file to the input folder first.") + return False + if not any(planned.values()): + tui.flash(stdscr, "Nothing to convert — every existing output was " + "kept.") + return False + return True + + def _preflight(stdscr, cmd: tuple) -> bool: """Run the overwrite checks in the TUI; stash the plan on the command. @@ -957,14 +1006,6 @@ def _preflight(stdscr, cmd: tuple) -> bool: voice_mode = voice_mode_for(backend, kwargs.get("voice"), kwargs.get("clone"), kwargs.get("instructions")) - - def confirm(message: str, default: bool) -> bool: - answer = tui.confirm(stdscr, message, default=default, - cancel_value=_CANCEL) - if answer is _CANCEL: - raise _BackToForm() - return answer - with contextlib.redirect_stdout(io.StringIO()): book_files, planned = AudiobookConverter.preflight_overwrites( backend=backend, voice=kwargs.get("voice"), @@ -972,14 +1013,8 @@ def _preflight(stdscr, cmd: tuple) -> bool: voice_clone_ref_audio=kwargs.get("clone"), output_format=kwargs.get("output_format") or config.AUDIO_FORMAT, instructions=kwargs.get("instructions"), - confirm=confirm) - if not book_files: - tui.flash(stdscr, "No books to convert. Add a .txt, .pdf or .epub " - "file to the input folder first.") - return False - if not planned: - tui.flash(stdscr, "Nothing to convert — every existing output was " - "kept.") + confirm=_tui_confirm(stdscr)) + if not _check_preflight_plan(stdscr, book_files, {"": planned}): return False kwargs["book_files"] = book_files kwargs["planned"] = planned @@ -1002,13 +1037,6 @@ def _preflight_all(stdscr, backend: str, kwargs: dict) -> bool: model_voices = kwargs.get("model_voices") or {} instructions = kwargs.get("instructions") - def confirm(message: str, default: bool) -> bool: - answer = tui.confirm(stdscr, message, default=default, - cancel_value=_CANCEL) - if answer is _CANCEL: - raise _BackToForm() - return answer - book_files: list = [] planned_by_model: dict = {} with contextlib.redirect_stdout(io.StringIO()): @@ -1021,18 +1049,12 @@ def _preflight_all(stdscr, backend: str, kwargs: dict) -> bool: voice_clone_ref_audio=kwargs.get("clone"), output_format=kwargs.get("output_format") or config.AUDIO_FORMAT, - instructions=instructions, confirm=confirm, + instructions=instructions, confirm=_tui_confirm(stdscr), name_tag=AudiobookConverter.compute_model_tag(model_id)) if not book_files: book_files = books planned_by_model[model_id] = planned - if not book_files: - tui.flash(stdscr, "No books to convert. Add a .txt, .pdf or .epub " - "file to the input folder first.") - return False - if not any(planned_by_model.values()): - tui.flash(stdscr, "Nothing to convert — every existing output was " - "kept.") + if not _check_preflight_plan(stdscr, book_files, planned_by_model): return False kwargs["book_files"] = book_files kwargs["planned_by_model"] = planned_by_model @@ -1348,31 +1370,16 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, def all_voice_for(model_id: str, picked: Optional[str]) -> Optional[str]: """The voice to send for MODEL_ID in an "All" run. - The picked voice wins wherever the model accepts it; models the - pick cannot serve fall back to their own default: the first - built-in speaker (CustomVoice) or first server voice (cloning), - or no voice at all (design entries and voice-less clone families - — the client then designs the voice from Instructions or - synthesizes plainly). + Single-sourced in audiocpp_voice_for_run (the same rules the + client documents): the pick wins where the model accepts it, and + models it does not fit fall back to their own default — the + first built-in speaker, first server voice, or no voice at all. """ entry = next((m for m in models if m.get("id") == model_id), models[0]) - capability = entry_capability(entry) - if capability == AUDIOCPP_VOICE_DESIGN: - return None - if capability == AUDIOCPP_VOICE_SPEAKER: - if picked and picked in QWEN3_TTS_SPEAKERS: - return picked - return QWEN3_TTS_SPEAKERS[0] - # Clone capability; the family policy decides whether a voice - # exists at all. - if audiocpp_family_voice_policy( - entry.get("family") or "") == AUDIOCPP_VOICE_NONE: - return None - voices = voices_for(model_id) - if picked and picked in voices: - return picked - return voices[0] if voices else None + return audiocpp_voice_for_run( + entry.get("family") or "", entry.get("task") or "tts", + entry.get("id") or "", picked, voices_for(model_id)) def all_voice_problem() -> Optional[str]: """Why an "All" run cannot start with the current settings, or None. @@ -1543,9 +1550,11 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, """The entry's capability words in fixed column order. Column 1 voices plain synthesis ("speaker" for built-in speakers, - "tts" for families that need no voice at all), column 2 is - "clone" when the entry clones a reference, column 3 "design" when - it can design a voice from an Instructions description. + "tts" for families that need no voice at all) — or the family's + kind when it cannot narrate text at all ("s2s", speech-to-speech). + Column 2 is "clone" when the entry clones a reference, column 3 + "design" when it can design a voice from an Instructions + description. """ family = entry.get("family") or "" task = entry.get("task") or "tts" @@ -1555,6 +1564,10 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, return ("speaker", "", "") if capability == AUDIOCPP_VOICE_DESIGN: return ("", "", "design") + if audiocpp_family_narrates(family) is False: + # Speech-to-speech-only family: labeling it "tts" would be the + # exact opposite of the truth. + return ("s2s", "", "") # The generic clone capability is refined by the family's voice # policy: pure-TTS families need no voice at all, mixed families # may run with or without one, clone-only families (and unknown @@ -1630,6 +1643,24 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, or (model_capability(fs) == AUDIOCPP_VOICE_CLONE and model_voice_policy(fs) == AUDIOCPP_VOICE_NONE)) + def model_validate(value) -> Optional[str]: + """Refuse a single-model pick that cannot synthesize narration. + + Speech-to-speech-only families (e.g. PersonaPlex) fail every + request regardless of hosting: the "All" path skips them, so the + single-model pick must refuse them too rather than start a doomed + run (the Voice field is hidden there, so voice_validate never + runs). The All pick is validated by voice_validate / + instructions_validate instead. + """ + if value == AUDIOCPP_MODEL_ALL: + return None + entry = model_entry(fields) + if audiocpp_family_narrates(entry.get("family") or "") is False: + return (f"'{entry.get('id')}' is speech-to-speech, not TTS: it " + "cannot turn text into audio. Pick a TTS model") + return None + def instructions_validate(value) -> Optional[str]: """Refuse a blank Instructions when the run needs it for a voice. @@ -1658,7 +1689,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, # The pick menu shows the padded capability table; the form row # collapses its column padding back to the two-space gutter. "compact_label": True, - "on_change": reset_voice}, + "on_change": reset_voice, + "validate": model_validate}, # The label tracks the entry's capability: a built-in speaker on # CustomVoice, otherwise the name of a server-side voice to clone. # Hidden on design entries (the voice is described) and on @@ -1755,8 +1787,9 @@ def _qwen_fields(remote_modes: Optional[list] = None, CustomVoice, a Clone .wav directory browser (default ./voices) + Voice- to-clone .wav picker on Base, Instructions on VoiceDesign — and MAPPER turns a submitted form values dict into the qwen converter - kwargs. qwen always has options to offer, so it never signals - unavailability. PREFIX namespaces the field keys ("" for the managed + kwargs. None (form omitted) when a filtered remote model list comes + back empty — no known mode matched what the remote demo reported. + PREFIX namespaces the field keys ("" for the managed entry) so two entries of this backend can share one form without overwriting each other. @@ -1785,6 +1818,10 @@ def _qwen_fields(remote_modes: Optional[list] = None, model_choices = [(label, value) for (label, value) in model_choices if dict(mode_keys)[value] in available] by_value = {value: label for label, value in model_choices} + if not model_choices: + # Nothing the remote demo can be hosting (its reported model name + # matched no known mode): the form cannot offer a model pick. + return None default_mode = "custom" if "custom" in by_value else model_choices[0][1] speakers = list(qwen_backend.QWEN_SPEAKERS) default_speaker = speakers[0] diff --git a/app/ui/runview.py b/app/ui/runview.py index 21ac08c..005db59 100644 --- a/app/ui/runview.py +++ b/app/ui/runview.py @@ -37,6 +37,7 @@ from typing import Callable, List, Optional from backends import common, servers from ui import tui +from ui import viewkit from ui.viewkit import (TERMINAL_PHASES as _TERMINAL, DRAW_TIMEOUT_MS as _DRAW_TIMEOUT_MS, ScreenView, _box, _fit, _format_elapsed, _sep, @@ -58,42 +59,23 @@ _SERVER_STATES = { _MONITOR_INTERVAL = 2.0 -class _LogAppender: +class _LogAppender(viewkit.LineSplitter): """A file-like that appends redirected console output to the run's log. The run view owns the screen, so anything a conversion prints to stdout/stderr outside the progress events would otherwise be swallowed - silently; this mirrors it line by line into the run's dated log file + silently; this mirrors it line by line (\\n and \\r — see + viewkit.LineSplitter) into the run's dated log file (RunConfig.log_path, the audiobook_ day stream), prefixed with the same timestamp format the converter's log records use. Best-effort: write errors are swallowed, and an empty path disables logging. """ def __init__(self, path: str): + super().__init__(self._append_line) self._path = path - self._buffer = "" - - def write(self, text: str) -> int: - if not text: - return 0 - self._buffer += text - while True: - cut = self._buffer.find("\n") - if cut < 0: - break - line, self._buffer = self._buffer[:cut], self._buffer[cut + 1:] - self._append(line) - return len(text) - - def flush(self) -> None: - if self._buffer: - self._append(self._buffer) - self._buffer = "" - - def isatty(self) -> bool: - return False - def _append(self, line: str) -> None: + def _append_line(self, line: str) -> None: if not self._path or not line.strip(): return try: @@ -274,6 +256,10 @@ class RunView(ScreenView): if event.get("cancelled"): self.cancelled = True self._finish("cancelled") + elif total == 0 and ok == 0 and not self.error_message: + # An empty run (no books found, or all skipped): a clean + # no-op, not a failure — there was nothing that could fail. + self._finish("done") elif total and ok >= total and not self.error_message: self._finish("done") else: @@ -404,21 +390,30 @@ class RunView(ScreenView): going. """ self._blocking() - answer = tui.confirm(self.scr, "Cancel processing?", default=False, - cancel_value=False) - if not answer: + try: + answer = tui.confirm(self.scr, "Cancel processing?", default=False, + cancel_value=False) + finally: self._nonblocking() + if not answer: return False self.cancelling = True self._cancel.set() # Wind the worker down BEFORE offering the server stop: killing the # server under a still-running request turns the cancellation into # request failures (reported as "failed" instead of "cancelled"). - self._worker.join(timeout=60) + # The join is best-effort — a wedged worker delays but cannot veto + # the flow below. + self._join_worker() # When this run booted the server, offer to shut it down too (the # boot path kills it itself when cancelled before ready); by now - # the worker is done, so nothing is mid-request. - self._confirm_stop_server() + # the worker is done (or wedged beyond saving), so nothing further + # is mid-request from this view's side. + self._blocking() + try: + self._confirm_stop_server() + finally: + self._nonblocking() self._drain() self.render() # One more key press acknowledges the final screen. diff --git a/app/ui/taskview.py b/app/ui/taskview.py index 3831ceb..e280673 100644 --- a/app/ui/taskview.py +++ b/app/ui/taskview.py @@ -53,10 +53,11 @@ from typing import Callable, List, Optional, Tuple import logging_kit from ui import tui +from ui import viewkit from ui.viewkit import (TERMINAL_PHASES as _TERMINAL, DRAW_TIMEOUT_MS as _DRAW_TIMEOUT_MS, - ScreenView, _box, _fit, _format_elapsed, _sep, - _text) + ScreenView, _box, _fit, _format_elapsed, _rect_box, + _sep, _text) # How many recent output lines the tail keeps in memory. The on-screen tail # draws as many as fit (see render); the full run is also mirrored to the @@ -411,9 +412,14 @@ class TaskView(ScreenView): return self._result_rc() def _result_rc(self) -> int: - """The exit code for the whole run (cancelled counts as failure).""" + """The exit code for the whole run: 0 ok, 130 cancelled, else first rc. + + 130 (the CLI's Ctrl-C code) distinguishes a user cancel from a + plain step failure, so callers like the hub can flash "cancelled" + instead of "failed". + """ if self.cancelled: - return 1 + return 130 return next((rc for rc in self.results if rc), 0) def _on_stop(self) -> None: @@ -432,7 +438,7 @@ class TaskView(ScreenView): return False self.cancelling = True self._cancel.set() - self._worker.join(timeout=60) + self._join_worker() return True # ------------------------------------------------------------------ @@ -542,42 +548,13 @@ class TaskView(ScreenView): # 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 +class _LineWriter(viewkit.LineSplitter): + """A file-like that forwards writes to a per-line callback (see + viewkit.LineSplitter for the \\r/\\n splitting).""" def _find_line_end(text: str) -> int: - """Index of the earliest ``\n`` or ``\r`` in TEXT, else -1.""" + """Index of the earliest ``\\n`` or ``\\r`` in TEXT, else -1.""" newline = text.find("\n") carriage = text.find("\r") if newline < 0: @@ -607,23 +584,6 @@ def _fmt_bytes(size: float) -> str: 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. @@ -846,9 +806,9 @@ class LanesView(_GetchModes): return self._clock() def _result_rc(self) -> int: - """The exit code for the whole run (cancelled counts as failure).""" + """The exit code for the whole run: 0 ok, 130 cancelled, else first rc.""" if self.cancelled: - return 1 + return 130 for lane in self._lanes: for rc in lane.results: if rc: @@ -906,7 +866,11 @@ class LanesView(_GetchModes): return key def _prompt_cancel(self) -> bool: - """Esc/q: confirm cancel, then wait for both workers to wind down.""" + """Esc/q: confirm cancel, then wait for the workers to wind down. + + Best-effort joins (see ScreenView._join_worker): a wedged lane + worker is left to its daemon fate rather than blocking the view. + """ self._blocking() try: answer = tui.confirm(self.scr, "Cancel this step?", default=False, @@ -918,8 +882,9 @@ class LanesView(_GetchModes): self.cancelling = True self._cancel.set() for lane in self._lanes: - if lane.worker is not None: - lane.worker.join(timeout=60) + worker = lane.worker + if worker is not None and worker.is_alive(): + worker.join(timeout=60) return True # -- drawing ----------------------------------------------------- diff --git a/app/ui/tui.py b/app/ui/tui.py index 1e95353..60a77dc 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -399,11 +399,11 @@ class Frame: # -- drawing --------------------------------------------------------- def _row_width(self, row: dict) -> int: - """Logical width of a row, including its indent.""" + """Display width of a row (wide chars count 2), plus its indent.""" if row["segments"] is not None: - return sum(len(text) for text, _ in row["segments"]) \ + return sum(_disp_width(text) for text, _ in row["segments"]) \ + 2 * row["indent"] - return len(row["text"]) + 2 * row["indent"] + return _disp_width(row["text"]) + 2 * row["indent"] def _status_extra(self) -> int: """Rows the status block needs beyond its single bottom row. @@ -417,19 +417,24 @@ class Frame: return max(0, len(self.status[0].split("\n")) - 1) 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) + """Dialog width: widest row plus frame, capped to the screen. + + Measured in display columns (wide chars count 2), so CJK text + gets a dialog wide enough to hold it untruncated. + """ + longest = max(_disp_width(self.title) + 4, + _disp_width(self.footer) + 4, 40) for row in self.rows: longest = max(longest, self._row_width(row) + 4) if self.status: # Measure per line: a multi-line status must not widen the # dialog to the combined length of its lines. - longest = max(longest, max(len(line) for line + longest = max(longest, max(_disp_width(line) for line in self.status[0].split("\n")) + 6) if self.buttons: labels, _ = self.buttons longest = max(longest, - sum(len(label) + 6 for label in labels) + 4) + sum(_disp_width(label) + 6 for label in labels) + 4) return min(longest + 4, width - 2) def _flatten(self, usable: int @@ -589,7 +594,7 @@ class Frame: # A wrapped row draws only its piece; an unwrapped one (piece is # None) draws all of row["segments"] (truncated at the border). segments = piece if piece is not None else row["segments"] - total = sum(len(text) for text, _ in segments) + total = sum(_disp_width(text) for text, _ in segments) if row["align"] == "left": x = inner_x + self.LIST_MARGIN + 2 * row["indent"] else: @@ -602,8 +607,8 @@ class Frame: if not text: break _addstr(scr, y, x, text, theme["bar"] if selected else attr) - x += len(text) - room -= len(text) + x += _disp_width(text) + room -= _disp_width(text) def _draw_text_row(self, y: int, row: dict, piece: Optional[str], inner_x: int, inner_w: int, selected: bool) -> None: @@ -890,7 +895,7 @@ def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0, if table_rows: if table_title: frame.mark(table_title, frame.theme["dim"], align="left") - name_w = max(len(row[0]) for row in table_rows) + name_w = max(_disp_width(row[0]) for row in table_rows) for row in table_rows: name, status, kind = row[0], row[1], row[2] name_kind = row[3] if len(row) > 3 else "body" @@ -1156,7 +1161,7 @@ def form(scr, title: str, fields: Sequence[dict], field_rows: List[int] = [] # visible field index -> row index # Labels can be callables, so the pad width is recomputed from the # visible fields on every redraw (a dynamic label's length may vary). - label_w = max((len(field_label(field)) for field in shown), + label_w = max((_disp_width(field_label(field)) for field in shown), default=0) for field in shown: if field.get("note"): diff --git a/app/ui/viewkit.py b/app/ui/viewkit.py index e54aada..7619db9 100644 --- a/app/ui/viewkit.py +++ b/app/ui/viewkit.py @@ -15,7 +15,7 @@ render methods build on. import threading import time from queue import Empty, Queue -from typing import List, Optional +from typing import Callable, List, Optional from ui import tui @@ -147,7 +147,14 @@ class ScreenView: return key def _prompt_cancel(self) -> bool: - """Esc/q: confirm cancel, then wait for the worker to wind down.""" + """Esc/q: confirm cancel, then wait for the worker to wind down. + + The join is best-effort: a worker wedged in un-killable work + (a stuck subprocess, a hung network call) is left running — the + view reports the cancel and returns, and the worker's daemon + thread dies with the process. Callers must not assume the thread + has stopped (see _join_worker). + """ self._blocking() try: answer = tui.confirm(self.scr, "Cancel this step?", default=False, @@ -158,9 +165,16 @@ class ScreenView: return False self.cancelling = True self._cancel.set() - self._worker.join(timeout=60) + self._join_worker() return True + def _join_worker(self, timeout: float = 60.0) -> None: + """Join the worker if it exists and was started; never raise.""" + worker = self._worker + if worker is None or not worker.is_alive(): + return + worker.join(timeout=timeout) + def _blocking(self) -> None: """Make getch block (used while a confirm dialog owns the screen).""" try: @@ -180,6 +194,45 @@ class ScreenView: # Shared drawing primitives # ---------------------------------------------------------------------- +class LineSplitter: + """A file-like that feeds each ``\\n``/``\\r``-terminated line to a sink. + + Carriage-return progress (git/tqdm) is treated as a line terminator, + so the sink sees each progress update immediately and the last full + line always reflects the latest state. ``flush()`` emits the + unterminated tail; ``isatty()`` is False. Both the task view's + console-mirror writer and the run view's log appender build on it, + so their line-splitting cannot drift apart. + """ + + def __init__(self, sink: Callable[[str], None]): + self._sink = sink + self._buffer = "" + + def write(self, text: str) -> int: + if not text: + return 0 + self._buffer += text + while True: + cut = min((cut for cut in (self._buffer.find("\n"), + self._buffer.find("\r")) + if cut >= 0), default=-1) + if cut < 0: + break + line, self._buffer = self._buffer[:cut], self._buffer[cut + 1:] + if line: + self._sink(line) + return len(text) + + def flush(self) -> None: + if self._buffer: + self._sink(self._buffer) + self._buffer = "" + + def isatty(self) -> bool: + return False + + def _text(scr, theme, y, x, text, attr) -> None: """addstr wrapper that ignores out-of-bounds errors.""" try: @@ -188,23 +241,28 @@ def _text(scr, theme, y, x, text, attr) -> None: pass -def _box(scr, curses, theme, height, width) -> None: - """Draw the full-screen frame.""" +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(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) + 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 +def _box(scr, curses, theme, height, width) -> None: + """Draw the full-screen frame.""" + _rect_box(scr, curses, theme, 0, 0, width, height) + + def _sep(scr, curses, theme, y, width) -> None: """A horizontal separator line inside the frame.""" try: |
