diff options
| author | historia <historiavg@proton.me> | 2026-08-26 03:02:23 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-26 03:02:23 -0400 |
| commit | c147087c9d4707bffaeee58d390653637a21cce8 (patch) | |
| tree | b080c40eaa388609dea38c2cc413cb912aa7b4af /app/ui | |
| parent | 8b5c8697740ff415cf7f1d03c9fb5a8c8851d420 (diff) | |
| download | tts-audiobook-generator-c147087c9d4707bffaeee58d390653637a21cce8.tar.gz | |
refactor: put shared ui screen code into ui.viewkit
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/hub.py | 14 | ||||
| -rw-r--r-- | app/ui/runview.py | 188 | ||||
| -rw-r--r-- | app/ui/taskview.py | 171 | ||||
| -rw-r--r-- | app/ui/viewkit.py | 251 |
4 files changed, 329 insertions, 295 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py index 5f29ed1..1c0cafa 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -36,6 +36,7 @@ from backends import ( ServerSpec, common, detect_all, + invalidate_detect_cache, get, servers, ) @@ -217,9 +218,11 @@ class _Hub: return self.screen_uninstall if choice == "download_models": _download_models_action(self.stdscr) + invalidate_detect_cache() continue # an inline action: re-show this same menu if choice == "build_audiocpp": audiocpp_backend.build_screen(self.stdscr) + invalidate_detect_cache() continue # an inline action: re-show this same menu _kind, key = choice info = get(key) @@ -236,6 +239,7 @@ class _Hub: """ def screen(): self._run_setup(info) + invalidate_detect_cache() return tui.Wizard.BACK return screen @@ -290,6 +294,7 @@ class _Hub: lambda emit, cancel: info.uninstall(emit=emit, cancel=cancel)) rc = taskview.run_steps(self.stdscr, title, [step], wait_on_finish=False) + invalidate_detect_cache() # The uninstallers warn-and-continue (a failed pip step still # returns 0), so rc == 0 means "finished"; anything else covers a # failure or an Esc-cancelled run between phases. @@ -398,6 +403,7 @@ class _Hub: self.stdscr.timeout(-1) except Exception: pass + invalidate_detect_cache() return False # -- settings ------------------------------------------------------- @@ -490,6 +496,7 @@ class _Hub: wait_on_finish=False) # Re-check the server instead of trusting the step's exit code # (cancel and failure both come back non-zero): did the toggle take? + invalidate_detect_cache() now_running = common.server_running(spec.url) if action == "start" and not now_running: tui.flash(self.stdscr, f"Could not start the {spec.name} " @@ -1391,6 +1398,8 @@ def _apply_settings(values: dict) -> None: if not common.update_config_value(name, value): raise ValueError(f"Could not save {name} to " f"{common.CONFIG_PATH}") + # Ports/URLs may have changed: the cached backend statuses are stale. + invalidate_detect_cache() def _read_port(values: dict, key: str) -> int: @@ -1465,7 +1474,8 @@ def _prepare_run_config(backend: str, kwargs: dict server_url=api_url, server_identity=identity, log_path=log_path, stop_and_exit=stop_and_exit) - status = next((s for s in detect_all() if s.key == backend), None) + status = next((s for s in detect_all(refresh=True) + if s.key == backend), None) notice = "" spec: Optional[ServerSpec] = None if autostart: @@ -1536,7 +1546,7 @@ def _select_spec(status, kwargs) -> Optional[ServerSpec]: def _find_spec(name: str) -> Optional[ServerSpec]: """Look up a server spec by name across every backend's detect().""" - for st in detect_all(): + for st in detect_all(refresh=True): for spec in st.servers: if spec.name == name: return spec diff --git a/app/ui/runview.py b/app/ui/runview.py index 7151ecb..a954499 100644 --- a/app/ui/runview.py +++ b/app/ui/runview.py @@ -32,16 +32,16 @@ import contextlib import io import threading import time -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime -from queue import Empty, Queue from typing import Callable, List, Optional from backends import common, servers from ui import tui - -# Terminal states: the run is over and the screen waits for a key. -_TERMINAL = ("done", "error", "cancelled") +from ui.viewkit import (TERMINAL_PHASES as _TERMINAL, + DRAW_TIMEOUT_MS as _DRAW_TIMEOUT_MS, + ScreenView, _box, _fit, _format_elapsed, _sep, + _text, _wrap) # Server panel states -> (text, theme kind) with the elapsed clock added # while booting. @@ -55,8 +55,7 @@ _SERVER_STATES = { "stopped": ("stopped", "info"), } -# Redraw cadence / poll cadence (milliseconds / seconds). -_DRAW_TIMEOUT_MS = 250 +# Poll cadence for the server monitor (seconds). _MONITOR_INTERVAL = 2.0 @@ -95,17 +94,13 @@ class RunConfig: stop_and_exit: bool = False -class RunView: +class RunView(ScreenView): """Draws and drives one conversion run; see the module docstring.""" def __init__(self, scr, config: RunConfig, clock: Callable[[], float] = time.time): - import curses - self.curses = curses - self.scr = scr + super().__init__(scr, clock=clock) self.config = config - self.theme = tui._ensure_theme(curses) - self._clock = clock # -- state ----------------------------------------------------- self.phase = "boot" # boot | convert | done | error | cancelled self.server = "starting" @@ -126,8 +121,6 @@ class RunView: self.stop_started: Optional[float] = None self.server_log_path = "" # -- threads --------------------------------------------------- - self._queue: Queue = Queue() - self._cancel = threading.Event() self._monitor_stop = threading.Event() self._worker = threading.Thread(target=self._worker_main, daemon=True) @@ -228,15 +221,6 @@ class RunView: "the conversion ended unexpectedly" self._finish("error") - def _finish(self, phase: str) -> None: - """Enter a terminal phase, freezing the elapsed clock.""" - self.phase = phase - if self.finished_at is None: - self.finished_at = self._now() - - def _now(self) -> float: - return self._clock() - # ------------------------------------------------------------------ # Threads # ------------------------------------------------------------------ @@ -313,57 +297,30 @@ class RunView: notice. Every other exit (a key press on the summary screen, the Esc cancel flow) lands back on the hub menu. """ - scr = self.scr - try: - self.scr.timeout(_DRAW_TIMEOUT_MS) - except Exception: - pass - self._worker.start() + return super().run() + + # ScreenView hooks ------------------------------------------------- + + def _start_workers(self) -> None: + super()._start_workers() monitor = threading.Thread(target=self._monitor_main, daemon=True) monitor.start() - try: - while True: - self._drain() - # The stop-and-exit setting never waits for a key: leave as - # soon as the run ends (an explicit Esc cancel keeps its own - # interactive flow instead). - if self.config.stop_and_exit and self.phase in _TERMINAL \ - and self.phase != "cancelled": - return self._auto_stop_and_exit() - self.render() - key = self._get_key() - if key is None: - continue - if self.phase in _TERMINAL: - return False - if key in (27, ord("q"), 3) and not self.cancelling: - if self._prompt_cancel(): - return False - finally: - self._monitor_stop.set() - self._cancel.set() - # Leave the screen blocking again: the timed redraw getch must - # not make later hub dialogs (e.g. tui.flash) dismiss themselves. - self._blocking() - - def _get_key(self) -> Optional[int]: - """One key from the screen (None on the redraw timeout).""" - try: - key = self.scr.getch() - except KeyboardInterrupt: - return 3 - if key == -1: - return None - return key - - def _drain(self) -> None: - """Fold every queued event into the state.""" - while True: - try: - event = self._queue.get_nowait() - except Empty: - return - self.handle_event(event) + + def _early_exit(self): + # The stop-and-exit setting never waits for a key: leave as + # soon as the run ends (an explicit Esc cancel keeps its own + # interactive flow instead). + if self.config.stop_and_exit and self.phase in _TERMINAL \ + and self.phase != "cancelled": + return self._auto_stop_and_exit() + return None + + def _terminal_result(self) -> bool: + return False + + def _on_stop(self) -> None: + self._monitor_stop.set() + super()._on_stop() def _prompt_cancel(self) -> bool: """The Esc/q flow: confirm cancel, then confirm stopping the server. @@ -494,20 +451,6 @@ class RunView: lines.append(f"Elapsed time: {_format_elapsed(elapsed)}") return "\n".join(lines) - def _blocking(self) -> None: - """Make getch block (used while a confirm dialog owns the screen).""" - try: - self.scr.timeout(-1) - except Exception: - pass - - def _nonblocking(self) -> None: - """Restore the redraw-cadence getch timeout.""" - try: - self.scr.timeout(_DRAW_TIMEOUT_MS) - except Exception: - pass - _server_stopped_confirmed = False # ------------------------------------------------------------------ @@ -700,77 +643,6 @@ class RunView: # Small drawing/formatting helpers (module-level for testability) # --------------------------------------------------------------------------- -def _text(scr, theme, y, x, text, attr) -> None: - """addstr wrapper that ignores out-of-bounds errors.""" - try: - scr.addstr(y, x, text, attr) - except Exception: - pass - - -def _box(scr, curses, theme, height, width) -> None: - """Draw the full-screen frame.""" - 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: - """A horizontal separator line inside the frame.""" - 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: - """Truncate TEXT to WIDTH columns, appending '~' when cut.""" - if width < 1: - return "" - if len(text) <= width: - return text - return text[: max(0, width - 1)] + "~" - - -def _wrap(text: str, width: int) -> List[str]: - """Greedy word wrap (no textwrap dependency on curses chars).""" - lines: List[str] = [] - current = "" - for word in text.split(): - candidate = f"{current} {word}".strip() - if len(candidate) <= max(10, width): - current = candidate - else: - if current: - lines.append(current) - current = word - if current: - lines.append(current) - return lines - - -def _format_elapsed(seconds: float) -> str: - """Format a duration as H:MM:SS / M:SS.""" - 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 run(scr, config: RunConfig) -> bool: """Enter the run view (called inside curses.wrapper by the hub). diff --git a/app/ui/taskview.py b/app/ui/taskview.py index 6708304..0d3b445 100644 --- a/app/ui/taskview.py +++ b/app/ui/taskview.py @@ -47,6 +47,10 @@ from queue import Empty, Queue from typing import Callable, List, Optional, Tuple from ui import tui +from ui.viewkit import (TERMINAL_PHASES as _TERMINAL, + DRAW_TIMEOUT_MS as _DRAW_TIMEOUT_MS, + ScreenView, _box, _fit, _format_elapsed, _sep, + _text) # Redraw cadence for the timed getch (milliseconds). _DRAW_TIMEOUT_MS = 250 @@ -192,22 +196,17 @@ def run_lanes(scr, title: str, lanes: List[TaskLane]) -> int: return LanesView(scr, title, lanes).run() -class TaskView: +class TaskView(ScreenView): """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 + super().__init__(scr, clock=clock) 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 @@ -312,46 +311,21 @@ class TaskView: 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() + # ScreenView hooks ------------------------------------------------ - def _now(self) -> float: - return self._clock() + def _early_exit(self): + """A no-wait run returns as soon as the steps are over.""" + if self.phase in _TERMINAL and not self.wait_on_finish: + return self._result_rc() + return None - # ------------------------------------------------------------------ - # Main loop - # ------------------------------------------------------------------ + def _terminal_result(self) -> int: + return self._result_rc() - 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() - # Leave the screen blocking again: the timed redraw getch must - # not make later hub dialogs (e.g. tui.flash) dismiss themselves. - self._blocking() + def _after_cancel(self) -> int: + # Fold the worker's final events in so the result reflects them. + self._drain() + return self._result_rc() def _result_rc(self) -> int: """The exit code for the whole run (cancelled counts as failure).""" @@ -359,23 +333,6 @@ class TaskView: 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() @@ -391,18 +348,6 @@ class TaskView: 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 # ------------------------------------------------------------------ @@ -548,55 +493,6 @@ def _find_line_end(text: str) -> int: 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": @@ -696,7 +592,24 @@ class _LaneState: self.finished = False -class LanesView: + +class _GetchModes: + """The blocking/non-blocking getch switching shared by all views.""" + + 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 + + +class LanesView(_GetchModes): """A full-screen task view that runs two step lists in parallel. The two-lane counterpart of ``TaskView``: each lane gets its own worker @@ -895,18 +808,6 @@ class LanesView: 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: diff --git a/app/ui/viewkit.py b/app/ui/viewkit.py new file mode 100644 index 0000000..301a0af --- /dev/null +++ b/app/ui/viewkit.py @@ -0,0 +1,251 @@ +"""Shared plumbing for full-screen views (the "viewkit"). + +``ScreenView`` is the base class behind TaskView and RunView: it owns the +event queue and drain loop, the cancellation event, the timed-redraw main +loop with its Esc/q cancel flow, and the blocking/non-blocking getch +switching that lets confirm dialogs own the screen. Subclasses provide +``handle_event``/``render`` plus small hooks for how a terminal phase +exits, and everything else — thread start-up, key handling, cleanup — is +identical across views. + +The drawing helpers at the bottom are the shared primitives both views' +render methods build on. +""" + +import threading +import time +from queue import Empty, Queue +from typing import List, Optional + +from ui import tui + +# Terminal states: the view's work is over and the screen waits for a key. +TERMINAL_PHASES = ("done", "error", "cancelled") + +# Redraw cadence for the timed getch (milliseconds). +DRAW_TIMEOUT_MS = 250 + + +class ScreenView: + """Base class for worker-thread-driven full-screen views. + + Subclasses set ``self._worker`` (a Thread running ``_worker_main``) + and implement ``handle_event(event)``, ``render()`` and + ``_terminal_result()``. The ``run`` template below drives everything + else; its behavior is tuned through the hooks: + + - ``_start_workers`` start threads (default: just the worker) + - ``_early_exit`` pre-render exit check (returns a result or None) + - ``_after_cancel`` result once the cancel flow completed + - ``_on_stop`` finally-block cleanup (cancel + block getch) + + ESC/Q/Ctrl-C asks ``_prompt_cancel`` (overridable); confirming sets + ``self.cancelling``/``self._cancel`` and winds the worker down. + """ + + def __init__(self, scr, clock=time.time): + import curses + self.curses = curses + self.scr = scr + self.theme = tui._ensure_theme(curses) + self._clock = clock + # -- state ----------------------------------------------------- + self.phase = "running" + self.finished_at: Optional[float] = None + self.cancelled = False + self.cancelling = False + # -- threads --------------------------------------------------- + self._queue: Queue = Queue() + self._cancel = threading.Event() + self._worker = None + + def _now(self) -> float: + return self._clock() + + def _finish(self, phase: str) -> None: + """Enter a terminal phase, freezing the elapsed clock.""" + self.phase = phase + if self.finished_at is None: + self.finished_at = self._now() + + # ------------------------------------------------------------------ + # Event plumbing + # ------------------------------------------------------------------ + + def handle_event(self, event: dict) -> None: + """Fold one queued event into the view state (no drawing).""" + raise NotImplementedError + + def _drain(self) -> None: + """Fold every queued event into the state.""" + while True: + try: + event = self._queue.get_nowait() + except Empty: + return + self.handle_event(event) + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + + def run(self): + """Drive the view until a terminal phase exits the loop.""" + try: + self.scr.timeout(DRAW_TIMEOUT_MS) + except Exception: + pass + self._start_workers() + try: + while True: + self._drain() + early = self._early_exit() + if early is not None: + return early + self.render() + key = self._get_key() + if key is None: + continue + if self.phase in TERMINAL_PHASES: + return self._terminal_result() + if key in (27, ord("q"), 3) and not self.cancelling: + if self._prompt_cancel(): + return self._after_cancel() + finally: + self._on_stop() + + def _start_workers(self) -> None: + if self._worker is not None: + self._worker.start() + + def _early_exit(self): + """Optional pre-render exit check; a non-None value ends the view.""" + return None + + def _terminal_result(self): + """The view's return value when the work reached a terminal phase.""" + raise NotImplementedError + + def _after_cancel(self): + """The view's return value after a confirmed cancel flow.""" + return self._terminal_result() + + def _on_stop(self) -> None: + self._cancel.set() + # Leave the screen blocking again: the timed redraw getch must + # not make later hub dialogs (e.g. tui.flash) dismiss themselves. + self._blocking() + + def _get_key(self) -> Optional[int]: + """One key from the screen (None on the redraw timeout).""" + 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 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: + """Make getch block (used while a confirm dialog owns the screen).""" + try: + self.scr.timeout(-1) + except Exception: + pass + + def _nonblocking(self) -> None: + """Restore the redraw-cadence getch timeout.""" + try: + self.scr.timeout(DRAW_TIMEOUT_MS) + except Exception: + pass + + +# ---------------------------------------------------------------------- +# Shared drawing primitives +# ---------------------------------------------------------------------- + +def _text(scr, theme, y, x, text, attr) -> None: + """addstr wrapper that ignores out-of-bounds errors.""" + try: + scr.addstr(y, x, text, attr) + except Exception: + pass + + +def _box(scr, curses, theme, height, width) -> None: + """Draw the full-screen frame.""" + 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: + """A horizontal separator line inside the frame.""" + 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: + """Truncate TEXT to WIDTH columns, appending '~' when cut.""" + if width < 1: + return "" + if len(text) <= width: + return text + return text[: max(0, width - 1)] + "~" + + +def _wrap(text: str, width: int) -> List[str]: + """Greedy word wrap (no textwrap dependency on curses chars).""" + lines: List[str] = [] + current = "" + for word in text.split(): + candidate = f"{current} {word}".strip() + if len(candidate) <= max(10, width): + current = candidate + else: + if current: + lines.append(current) + current = word + if current: + lines.append(current) + return lines + + +def _format_elapsed(seconds: float) -> str: + """Format a duration as H:MM:SS / M:SS.""" + 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}" |
