aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 03:02:23 -0400
committerhistoria <historiavg@proton.me>2026-08-26 03:02:23 -0400
commitc147087c9d4707bffaeee58d390653637a21cce8 (patch)
treeb080c40eaa388609dea38c2cc413cb912aa7b4af /app
parent8b5c8697740ff415cf7f1d03c9fb5a8c8851d420 (diff)
downloadtts-audiobook-generator-c147087c9d4707bffaeee58d390653637a21cce8.tar.gz
refactor: put shared ui screen code into ui.viewkit
Diffstat (limited to 'app')
-rw-r--r--app/backends/__init__.py42
-rw-r--r--app/tests/test_backends.py55
-rw-r--r--app/tests/test_viewkit.py144
-rw-r--r--app/ui/hub.py14
-rw-r--r--app/ui/runview.py188
-rw-r--r--app/ui/taskview.py171
-rw-r--r--app/ui/viewkit.py251
7 files changed, 566 insertions, 299 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py
index f6a1d9b..2b7fbe6 100644
--- a/app/backends/__init__.py
+++ b/app/backends/__init__.py
@@ -25,10 +25,17 @@ registry.
"""
import shlex
+import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Dict, List, Optional
+# How long detect_all() results stay fresh (see detect_all). Long enough to
+# cover a burst of menu renders, short enough that state changed by an
+# outside actor (a remote server appearing) surfaces promptly.
+DETECT_TTL_SECONDS = 2.0
+_detect_cache = None
+
@dataclass
class ServerSpec:
@@ -200,10 +207,39 @@ def get(key: str) -> Optional[BackendInfo]:
return _BY_KEY.get(key)
-def detect_all() -> List[BackendStatus]:
- """Detect every registered backend's status, in registry order."""
+def detect_all(*, refresh: bool = False) -> List[BackendStatus]:
+ """Detect every registered backend's status, in registry order.
+
+ Detection is not free — each backend probes the filesystem and, for
+ remote servers, the network — so results are cached for a short
+ window (DETECT_TTL_SECONDS). Menu renders that happen in quick
+ succession (popping back and forth between hub screens) reuse the
+ cached statuses; anything past the TTL re-probes. REFRESH forces an
+ immediate re-detection: callers use it right after an action that can
+ change status (setup, uninstall, server start/stop) so the next render
+ never shows stale state.
+ """
_build_registry()
- return [info.detect() for info in REGISTRY]
+ global _detect_cache
+ now = time.monotonic()
+ if not refresh and _detect_cache is not None:
+ at, statuses = _detect_cache
+ if now - at < DETECT_TTL_SECONDS:
+ return list(statuses)
+ statuses = [info.detect() for info in REGISTRY]
+ _detect_cache = (now, statuses)
+ return list(statuses)
+
+
+def invalidate_detect_cache() -> None:
+ """Drop the cached statuses so the next detect_all() re-probes.
+
+ Called by the hub after any action that can change a backend's on-disk
+ or running state (setup wizards, uninstallers, server toggles,
+ conversion runs with autostart, settings writes).
+ """
+ global _detect_cache
+ _detect_cache = None
def detect(key: str) -> Optional[BackendStatus]:
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py
index 4ff6f6e..dc2b4d0 100644
--- a/app/tests/test_backends.py
+++ b/app/tests/test_backends.py
@@ -5,7 +5,16 @@ import unittest
from pathlib import Path
from unittest.mock import patch
-from backends import REGISTRY, ServerSpec, detect_all, format_launch_hint, get
+import backends
+from backends import (
+ REGISTRY,
+ BackendStatus,
+ ServerSpec,
+ detect_all,
+ format_launch_hint,
+ get,
+ invalidate_detect_cache,
+)
class FormatLaunchHintTests(unittest.TestCase):
@@ -294,6 +303,50 @@ class RemoteSuppressionTests(unittest.TestCase):
self.assertEqual(status.remote_urls, {})
+class DetectCacheTests(unittest.TestCase):
+ """detect_all's short-TTL cache (menu renders re-probe only after it)."""
+
+ def setUp(self):
+ get("audiocpp") # build the lazy registry before patching its entries
+ invalidate_detect_cache()
+ self.addCleanup(invalidate_detect_cache)
+ self.probes = []
+ self.patches = []
+ for info in REGISTRY:
+ def fake_detect(key=info.key):
+ self.probes.append(key)
+ return BackendStatus(key, key, installed=False, configured=False)
+ self.patches.append(patch.object(info, "detect",
+ side_effect=fake_detect))
+ for p in self.patches:
+ p.start()
+ self.addCleanup(p.stop)
+
+ def test_repeated_calls_within_the_ttl_probe_once(self):
+ first = detect_all()
+ second = detect_all()
+ self.assertEqual(first, second)
+ self.assertEqual(sorted(self.probes), sorted(i.key for i in REGISTRY))
+ self.assertEqual(len(self.probes), len(REGISTRY))
+
+ def test_refresh_bypasses_the_cache(self):
+ detect_all()
+ detect_all(refresh=True)
+ self.assertEqual(len(self.probes), 2 * len(REGISTRY))
+
+ def test_invalidate_forces_the_next_call_to_reprobe(self):
+ detect_all()
+ invalidate_detect_cache()
+ detect_all()
+ self.assertEqual(len(self.probes), 2 * len(REGISTRY))
+
+ def test_expiry_after_the_ttl_reprobes(self):
+ with patch.object(backends, "DETECT_TTL_SECONDS", 0.0):
+ detect_all()
+ detect_all()
+ self.assertEqual(len(self.probes), 2 * len(REGISTRY))
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/app/tests/test_viewkit.py b/app/tests/test_viewkit.py
new file mode 100644
index 0000000..efebae6
--- /dev/null
+++ b/app/tests/test_viewkit.py
@@ -0,0 +1,144 @@
+"""Tests for the shared full-screen view plumbing (ui/viewkit.py).
+
+ScreenView is the base behind TaskView and RunView; those views' suites
+cover it end to end. These tests pin the template-loop contract itself:
+worker start-up, event draining, terminal/key handling, and the getch
+mode switching.
+"""
+
+import sys
+import threading
+import unittest
+from unittest.mock import patch
+
+from tests.test_tui import FakeCurses, FakeScreen
+from ui import viewkit
+from ui.viewkit import ScreenView
+
+
+class _SyncThread:
+ """Thread stand-in that runs its target synchronously on start()."""
+
+ def __init__(self, target, daemon=None):
+ self._target = target
+
+ def start(self):
+ self._target()
+
+ def join(self, timeout=None):
+ pass
+
+
+class EchoView(ScreenView):
+ """Minimal concrete view: records keys, ends after N events."""
+
+ def __init__(self, scr, *, terminal_after=None, clock=lambda: 1000.0):
+ super().__init__(scr, clock=clock)
+ self.keys = []
+ self.terminal_after = terminal_after
+
+ def work():
+ for index in range(self.terminal_after or 0):
+ self._queue.put({"kind": "tick", "index": index})
+ if self.terminal_after is not None:
+ self._finish("done")
+ # Created here; the base template starts it.
+ self._worker = _SyncThread(work)
+
+ def handle_event(self, event):
+ pass
+
+ def render(self):
+ pass
+
+ def _terminal_result(self):
+ return "terminal"
+
+
+class TemplateLoopTests(unittest.TestCase):
+ def setUp(self):
+ self.curses = FakeCurses()
+ patcher = patch.dict(sys.modules, {"curses": self.curses})
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ viewkit.tui._THEME.clear()
+ self.addCleanup(viewkit.tui._THEME.clear)
+
+ def _view(self, keys, **kwargs):
+ screen = FakeScreen(keys=keys)
+ return EchoView(screen, **kwargs), screen
+
+ def test_worker_runs_and_terminal_key_ends_the_view(self):
+ view, screen = self._view(keys=[ord("x")], terminal_after=2)
+ self.assertEqual(view.run(), "terminal")
+ # Both queued events were drained before the key was read.
+ self.assertEqual(view.phase, "done")
+
+ def test_timeout_getch_keeps_the_loop_going(self):
+ # -1 (redraw timeout) events never end the loop.
+ view, screen = self._view(keys=[-1, ord("x")], terminal_after=1)
+ self.assertEqual(view.run(), "terminal")
+
+ def test_esc_confirms_cancel_then_ends_via_after_cancel(self):
+ view, screen = self._view(keys=[27])
+ with patch.object(view, "_prompt_cancel", return_value=True) as mk, \
+ patch.object(view, "_after_cancel",
+ return_value="cancelled") as mk_after:
+ self.assertEqual(view.run(), "cancelled")
+ mk.assert_called_once()
+ mk_after.assert_called_once()
+
+ def test_esc_declined_keeps_the_view_running(self):
+ view, screen = self._view(keys=[27, ord("x")], terminal_after=1)
+ with patch.object(view, "_prompt_cancel", return_value=False):
+ self.assertEqual(view.run(), "terminal")
+
+ def test_early_exit_wins_before_render(self):
+ view, screen = self._view(keys=[], terminal_after=1)
+ with patch.object(view, "_early_exit", return_value="early"), \
+ patch.object(view, "render") as mk_render:
+ self.assertEqual(view.run(), "early")
+ mk_render.assert_not_called()
+
+ def test_stop_hook_restores_blocking_getch(self):
+ view, screen = self._view(keys=[ord("x")], terminal_after=1)
+ view.run()
+ self.assertEqual(screen.timeouts[-1], -1)
+
+
+class DefaultPromptCancelTests(unittest.TestCase):
+ def setUp(self):
+ self.curses = FakeCurses()
+ patcher = patch.dict(sys.modules, {"curses": self.curses})
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ viewkit.tui._THEME.clear()
+ self.addCleanup(viewkit.tui._THEME.clear)
+
+ def test_confirm_sets_cancel_and_joins_the_worker(self):
+ screen = FakeScreen(keys=[])
+ view = EchoView(screen)
+ joined = threading.Event()
+
+ class Worker:
+ def join(self, timeout=None):
+ joined.set()
+
+ view._worker = Worker()
+ with patch.object(viewkit.tui, "confirm", return_value=True):
+ self.assertTrue(view._prompt_cancel())
+ self.assertTrue(joined.is_set())
+ self.assertTrue(view.cancelling)
+ self.assertTrue(view._cancel.is_set())
+
+ def test_declined_cancel_leaves_the_run_alone(self):
+ screen = FakeScreen(keys=[])
+ view = EchoView(screen)
+ with patch.object(viewkit.tui, "confirm", return_value=False):
+ self.assertFalse(view._prompt_cancel())
+ self.assertFalse(view.cancelling)
+ self.assertFalse(view._cancel.is_set())
+
+
+if __name__ == "__main__":
+ unittest.main()
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}"