aboutsummaryrefslogtreecommitdiff
path: root/app/ui
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py191
-rw-r--r--app/ui/runview.py55
-rw-r--r--app/ui/taskview.py85
-rw-r--r--app/ui/tui.py29
-rw-r--r--app/ui/viewkit.py86
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: