vir-tui 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vir_tui/__init__.py +45 -0
- vir_tui/core.py +106 -0
- vir_tui/menu.py +1020 -0
- vir_tui-2.2.0.dist-info/METADATA +41 -0
- vir_tui-2.2.0.dist-info/RECORD +7 -0
- vir_tui-2.2.0.dist-info/WHEEL +4 -0
- vir_tui-2.2.0.dist-info/licenses/LICENSE +21 -0
vir_tui/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from .core import (
|
|
2
|
+
info,
|
|
3
|
+
success,
|
|
4
|
+
warn,
|
|
5
|
+
error,
|
|
6
|
+
dry_run,
|
|
7
|
+
print_header,
|
|
8
|
+
print_summary,
|
|
9
|
+
color,
|
|
10
|
+
tqdm,
|
|
11
|
+
)
|
|
12
|
+
from .menu import (
|
|
13
|
+
CP_FRAME,
|
|
14
|
+
CP_HEADER,
|
|
15
|
+
CP_HINT,
|
|
16
|
+
CP_ITEM,
|
|
17
|
+
CP_SELECTED,
|
|
18
|
+
CP_TITLE,
|
|
19
|
+
ProgressBox,
|
|
20
|
+
confirm,
|
|
21
|
+
interactive_session,
|
|
22
|
+
out_note,
|
|
23
|
+
progress_box,
|
|
24
|
+
prompt_float,
|
|
25
|
+
prompt_path,
|
|
26
|
+
tui_select,
|
|
27
|
+
build_fallback,
|
|
28
|
+
reset_terminal,
|
|
29
|
+
open_screen,
|
|
30
|
+
close_screen,
|
|
31
|
+
session_screen,
|
|
32
|
+
text_mode,
|
|
33
|
+
ask,
|
|
34
|
+
ask_yn,
|
|
35
|
+
prompt_int,
|
|
36
|
+
prompt_out,
|
|
37
|
+
run_with_capture,
|
|
38
|
+
CancelledError,
|
|
39
|
+
notify,
|
|
40
|
+
tui_page,
|
|
41
|
+
box_menu,
|
|
42
|
+
fallback_input,
|
|
43
|
+
_Cancelled, # for backwards compatibility
|
|
44
|
+
capture_output,
|
|
45
|
+
)
|
vir_tui/core.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from tqdm import tqdm
|
|
6
|
+
except ImportError:
|
|
7
|
+
# Minimal fallback if tqdm is missing
|
|
8
|
+
class tqdm:
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
iterable=None,
|
|
12
|
+
desc=None,
|
|
13
|
+
disable=False,
|
|
14
|
+
total=None,
|
|
15
|
+
unit="it",
|
|
16
|
+
leave=True,
|
|
17
|
+
**kwargs,
|
|
18
|
+
):
|
|
19
|
+
self.iterable = iterable
|
|
20
|
+
self.desc = desc
|
|
21
|
+
self.disable = disable
|
|
22
|
+
self.total = total
|
|
23
|
+
self.unit = unit
|
|
24
|
+
self.leave = leave
|
|
25
|
+
self.n = 0
|
|
26
|
+
if not self.disable and self.desc:
|
|
27
|
+
print(f"{self.desc}...", file=sys.stderr)
|
|
28
|
+
|
|
29
|
+
def __iter__(self):
|
|
30
|
+
if self.iterable is None:
|
|
31
|
+
return self
|
|
32
|
+
for item in self.iterable:
|
|
33
|
+
yield item
|
|
34
|
+
self.update(1)
|
|
35
|
+
|
|
36
|
+
def update(self, n=1):
|
|
37
|
+
self.n += n
|
|
38
|
+
|
|
39
|
+
def close(self):
|
|
40
|
+
if not self.disable and self.leave and self.total is not None:
|
|
41
|
+
print(f"Finished {self.n}/{self.total} {self.unit}", file=sys.stderr)
|
|
42
|
+
|
|
43
|
+
def set_description(self, desc):
|
|
44
|
+
self.desc = desc
|
|
45
|
+
|
|
46
|
+
def set_postfix(self, **kwargs):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def write(s, file=None, end="\n"):
|
|
51
|
+
print(s, file=file or sys.stdout, end=end)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ANSI Colors
|
|
55
|
+
RED = "\033[31m"
|
|
56
|
+
GREEN = "\033[32m"
|
|
57
|
+
YELLOW = "\033[33m"
|
|
58
|
+
CYAN = "\033[36m"
|
|
59
|
+
MAGENTA = "\033[35m"
|
|
60
|
+
BOLD = "\033[1m"
|
|
61
|
+
DIM = "\033[2m"
|
|
62
|
+
RESET = "\033[0m"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _use_color() -> bool:
|
|
66
|
+
return "NO_COLOR" not in os.environ and sys.stdout.isatty()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def color(text: str, code: str) -> str:
|
|
70
|
+
return f"{code}{text}{RESET}" if _use_color() else text
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def info(msg: str) -> str:
|
|
74
|
+
return color(f"ℹ {msg}", CYAN)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def success(msg: str) -> str:
|
|
78
|
+
return color(f"✓ {msg}", GREEN)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def warn(msg: str) -> str:
|
|
82
|
+
return color(f"⚠ {msg}", YELLOW)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def error(msg: str) -> str:
|
|
86
|
+
return color(f"✗ {msg}", RED)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def dry_run(msg: str) -> str:
|
|
90
|
+
return f"{color('[DRY]', YELLOW)} {msg}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def print_header(title: str) -> None:
|
|
94
|
+
tqdm.write(color(f"\n{'=' * 60}", BOLD))
|
|
95
|
+
tqdm.write(color(f"{title}", BOLD + CYAN))
|
|
96
|
+
tqdm.write(color(f"{'=' * 60}", BOLD))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def print_summary(stats: dict) -> None:
|
|
100
|
+
tqdm.write(color("\n--- SUMMARY ---", BOLD))
|
|
101
|
+
for k, v in stats.items():
|
|
102
|
+
if isinstance(v, int) and v > 0:
|
|
103
|
+
tqdm.write(f" {k}: {color(str(v), GREEN)}")
|
|
104
|
+
else:
|
|
105
|
+
tqdm.write(f" {k}: {v}")
|
|
106
|
+
tqdm.write(color("===============\n", BOLD))
|
vir_tui/menu.py
ADDED
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import traceback
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import Any, Self
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import curses
|
|
11
|
+
|
|
12
|
+
HAVE_CURSES = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
HAVE_CURSES = False
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
|
|
18
|
+
# =====================================
|
|
19
|
+
# Curses TUI / Fallbacks
|
|
20
|
+
# =====================================
|
|
21
|
+
|
|
22
|
+
_USE_CURSES = HAVE_CURSES and sys.stdin.isatty()
|
|
23
|
+
|
|
24
|
+
# T7: one persistent curses screen per interactive session. interactive_menu
|
|
25
|
+
# opens it once and every widget draws into it, so multi-prompt flows no
|
|
26
|
+
# longer flash to the shell between widgets (each widget used to be its own
|
|
27
|
+
# curses.wrapper init/teardown). None when no session owns a screen — widgets
|
|
28
|
+
# invoked directly then fall back to a one-shot wrapper session.
|
|
29
|
+
_SCREEN = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _with_screen(fn):
|
|
33
|
+
"""Run a widget body against the session's persistent screen, or in a
|
|
34
|
+
one-shot curses.wrapper session when no session owns one. Colors are
|
|
35
|
+
initialized here (or in _open_screen), not per widget."""
|
|
36
|
+
if _SCREEN is not None:
|
|
37
|
+
return fn(_SCREEN)
|
|
38
|
+
|
|
39
|
+
def _boot(stdscr):
|
|
40
|
+
_init_tui_colors()
|
|
41
|
+
return fn(stdscr)
|
|
42
|
+
|
|
43
|
+
return curses.wrapper(_boot)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def open_screen():
|
|
47
|
+
"""Start the session screen (initscr + the modes curses.wrapper would
|
|
48
|
+
set). Returns the screen, or None when curses can't start on this
|
|
49
|
+
terminal — the caller degrades the whole session to the text menu."""
|
|
50
|
+
global _SCREEN, _USE_CURSES
|
|
51
|
+
try:
|
|
52
|
+
stdscr = curses.initscr()
|
|
53
|
+
curses.noecho()
|
|
54
|
+
curses.cbreak()
|
|
55
|
+
stdscr.keypad(True)
|
|
56
|
+
_init_tui_colors()
|
|
57
|
+
_SCREEN = stdscr
|
|
58
|
+
_USE_CURSES = True
|
|
59
|
+
return stdscr
|
|
60
|
+
except curses.error:
|
|
61
|
+
# initscr may have partially engaged the terminal; put it back.
|
|
62
|
+
try:
|
|
63
|
+
if not curses.isendwin():
|
|
64
|
+
curses.endwin()
|
|
65
|
+
except curses.error:
|
|
66
|
+
pass
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def close_screen() -> None:
|
|
71
|
+
"""End the session screen. Idempotent and guarded, so it is safe after a
|
|
72
|
+
mid-session degrade already ended the screen."""
|
|
73
|
+
global _SCREEN
|
|
74
|
+
_SCREEN = None
|
|
75
|
+
|
|
76
|
+
if not HAVE_CURSES:
|
|
77
|
+
return
|
|
78
|
+
try:
|
|
79
|
+
if not curses.isendwin():
|
|
80
|
+
try:
|
|
81
|
+
curses.echo()
|
|
82
|
+
curses.nocbreak()
|
|
83
|
+
except curses.error:
|
|
84
|
+
pass
|
|
85
|
+
curses.endwin()
|
|
86
|
+
except curses.error:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def session_screen():
|
|
91
|
+
"""The persistent curses screen owned by an open interactive session, or
|
|
92
|
+
None. Host apps publish it to their own widgets (progress boxes) so those
|
|
93
|
+
render into the session's screen instead of starting one of their own."""
|
|
94
|
+
return _SCREEN
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def text_mode() -> bool:
|
|
98
|
+
"""True when the session runs without curses — no TTY, no curses module,
|
|
99
|
+
or a mid-session degrade. Hosts use it to pick text-only affordances
|
|
100
|
+
(e.g. printing an error line at a text menu instead of redrawing)."""
|
|
101
|
+
return not _USE_CURSES
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@contextmanager
|
|
105
|
+
def interactive_session():
|
|
106
|
+
"""Own the persistent session screen for a host's interactive menu loop.
|
|
107
|
+
|
|
108
|
+
Opens the curses session (yielding None when the terminal can't support
|
|
109
|
+
one, with the whole session degraded to the text fallback) and always
|
|
110
|
+
closes it on exit. KeyboardInterrupt is re-raised after cleanup so hosts
|
|
111
|
+
keep translating it into their own exit code (conventionally 130) without
|
|
112
|
+
the terminal staying broken. Replaces the open_screen/try/finally/
|
|
113
|
+
close_screen boilerplate CalibreQuarry and Lattice each carried.
|
|
114
|
+
"""
|
|
115
|
+
stdscr = open_screen()
|
|
116
|
+
if _USE_CURSES and stdscr is None:
|
|
117
|
+
_degrade_to_text()
|
|
118
|
+
try:
|
|
119
|
+
try:
|
|
120
|
+
yield stdscr
|
|
121
|
+
except KeyboardInterrupt:
|
|
122
|
+
if _SCREEN is None:
|
|
123
|
+
print()
|
|
124
|
+
raise
|
|
125
|
+
finally:
|
|
126
|
+
close_screen()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _degrade_to_text() -> None:
|
|
130
|
+
"""A mid-session curses failure (terminal died, capability lost): suspend
|
|
131
|
+
the screen and flip the whole session to the text fallback. endwin puts
|
|
132
|
+
the terminal back in normal mode and nothing refreshes it afterwards, so
|
|
133
|
+
plain print/input work from here on."""
|
|
134
|
+
global _USE_CURSES
|
|
135
|
+
_USE_CURSES = False
|
|
136
|
+
|
|
137
|
+
close_screen()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class CancelledError(Exception):
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_Cancelled = CancelledError
|
|
145
|
+
"""Raised when the user cancels a prompt (Esc in the TUI, Ctrl-C/EOF at a
|
|
146
|
+
text prompt); the active prompt chain unwinds back to the menu instead of
|
|
147
|
+
launching a mode with defaults."""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _prompt_str(label: str, default: str | None) -> str | None:
|
|
151
|
+
"""One prompt. Returns the entered value (the default on bare Enter), or
|
|
152
|
+
None when the user cancelled."""
|
|
153
|
+
if _USE_CURSES:
|
|
154
|
+
return _tui_prompt_str(label, default)
|
|
155
|
+
display = default if default else ""
|
|
156
|
+
try:
|
|
157
|
+
raw = input(f" {label} [{display}]: ").strip()
|
|
158
|
+
except EOFError, KeyboardInterrupt:
|
|
159
|
+
print()
|
|
160
|
+
return None
|
|
161
|
+
return raw or (default or "")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def ask(label: str, default: str | None) -> str:
|
|
165
|
+
"""_prompt_str that raises _Cancelled instead of returning None, so a
|
|
166
|
+
multi-prompt handler aborts as one unit."""
|
|
167
|
+
val = _prompt_str(label, default)
|
|
168
|
+
if val is None:
|
|
169
|
+
raise _Cancelled
|
|
170
|
+
return val
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def ask_yn(label: str, default: str = "N") -> bool:
|
|
174
|
+
return ask(label, default).lower().startswith("y")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def prompt_out(label: str, default: str) -> str:
|
|
178
|
+
"""Output-path prompt: expands ~ (no shell is there to do it) but is not
|
|
179
|
+
made absolute, so relative paths keep their current meaning."""
|
|
180
|
+
return os.path.expanduser(ask(label, default) or default)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _out_note(path: str | None) -> str:
|
|
184
|
+
"""Results-pager footer saying where a report landed, so 'where did my
|
|
185
|
+
report go' answers itself."""
|
|
186
|
+
return f"Report written to {os.path.abspath(path)}" if path else ""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def out_note(path: str | None) -> str:
|
|
190
|
+
"""Public form of :func:`_out_note` — hosts used to copy this helper
|
|
191
|
+
verbatim because only the underscored name existed."""
|
|
192
|
+
return _out_note(path)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def prompt_int(label: str, default: int) -> int:
|
|
196
|
+
prompt = label
|
|
197
|
+
while True:
|
|
198
|
+
s = ask(prompt, str(default)).strip()
|
|
199
|
+
try:
|
|
200
|
+
return int(s)
|
|
201
|
+
except ValueError:
|
|
202
|
+
prompt = f"{label} (not a number, try again)"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def prompt_float(
|
|
206
|
+
label: str,
|
|
207
|
+
default: float,
|
|
208
|
+
lo: float | None = None,
|
|
209
|
+
hi: float | None = None,
|
|
210
|
+
) -> float:
|
|
211
|
+
"""Float prompt with optional inclusive bounds; re-asks with a reason on
|
|
212
|
+
non-numeric or out-of-range input. Esc still cancels (CancelledError)."""
|
|
213
|
+
prompt = label
|
|
214
|
+
while True:
|
|
215
|
+
s = ask(prompt, f"{default:g}").strip()
|
|
216
|
+
try:
|
|
217
|
+
val = float(s)
|
|
218
|
+
except ValueError:
|
|
219
|
+
prompt = f"{label} (not a number, try again)"
|
|
220
|
+
continue
|
|
221
|
+
if lo is not None and hi is not None and not lo <= val <= hi:
|
|
222
|
+
prompt = f"{label} (must be between {lo:g} and {hi:g})"
|
|
223
|
+
continue
|
|
224
|
+
if lo is not None and val < lo:
|
|
225
|
+
prompt = f"{label} (must be >= {lo:g})"
|
|
226
|
+
continue
|
|
227
|
+
if hi is not None and val > hi:
|
|
228
|
+
prompt = f"{label} (must be <= {hi:g})"
|
|
229
|
+
continue
|
|
230
|
+
return val
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def prompt_path(label: str, default: str = "", *, must_exist: bool = True) -> str:
|
|
234
|
+
"""Path prompt: expands `~` and returns an absolute path. With
|
|
235
|
+
``must_exist`` it re-asks (with a notice) until the path exists, so
|
|
236
|
+
hosts stop hand-rolling existence loops. Esc still cancels."""
|
|
237
|
+
prompt = label
|
|
238
|
+
while True:
|
|
239
|
+
raw = ask(prompt, default)
|
|
240
|
+
path = os.path.abspath(os.path.expanduser(raw))
|
|
241
|
+
if not must_exist or os.path.exists(path):
|
|
242
|
+
return path
|
|
243
|
+
notify(f"Not found: {path}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def confirm(label: str, default: bool = False, *, danger: bool = False) -> bool:
|
|
247
|
+
"""Yes/no gate worded for destructive actions: ``danger`` prefixes the
|
|
248
|
+
label and defaults to No, so a bare Enter never destroys anything."""
|
|
249
|
+
text = f"DANGER — {label}" if danger else label
|
|
250
|
+
return ask_yn(text, "y" if default else "N")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def notify(msg: str) -> None:
|
|
254
|
+
"""A notice the user must see before the next menu redraw."""
|
|
255
|
+
if _USE_CURSES:
|
|
256
|
+
tui_page("Notice", msg)
|
|
257
|
+
else:
|
|
258
|
+
print(f" {msg}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def box_menu(title: str, sections: list, width: int = 44) -> None:
|
|
262
|
+
"""Fallback text menu for environments without curses."""
|
|
263
|
+
iw = width - 4
|
|
264
|
+
print(f"\n ╔{'═' * (width - 2)}╗")
|
|
265
|
+
print(f" ║ {title:^{iw}} ║")
|
|
266
|
+
print(f" ╠{'═' * (width - 2)}╣")
|
|
267
|
+
first = True
|
|
268
|
+
for header, items in sections:
|
|
269
|
+
if not first:
|
|
270
|
+
print(f" ╟{'─' * (width - 2)}╢")
|
|
271
|
+
first = False
|
|
272
|
+
if header:
|
|
273
|
+
print(f" ║ {header:<{iw - 1}} ║")
|
|
274
|
+
for item in items:
|
|
275
|
+
print(f" ║ {item:<{iw - 3}} ║")
|
|
276
|
+
print(f" ╚{'═' * (width - 2)}╝")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _pause() -> None:
|
|
280
|
+
"""Wait for user acknowledgement before redrawing."""
|
|
281
|
+
if _USE_CURSES:
|
|
282
|
+
_tui_pause()
|
|
283
|
+
return
|
|
284
|
+
try:
|
|
285
|
+
input("\n Press Enter to continue...")
|
|
286
|
+
except EOFError, KeyboardInterrupt:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
_CP_FRAME = 1
|
|
291
|
+
_CP_TITLE = 2
|
|
292
|
+
_CP_HEADER = 3
|
|
293
|
+
_CP_ITEM = 4
|
|
294
|
+
_CP_SELECTED = 5
|
|
295
|
+
_CP_HINT = 6
|
|
296
|
+
|
|
297
|
+
# Public aliases: hosts rendering their own widgets into the session screen
|
|
298
|
+
# (progress boxes, custom panels) use these instead of hand-mirroring the
|
|
299
|
+
# private numeric ids, which would break silently if vir-tui restyled.
|
|
300
|
+
CP_FRAME = _CP_FRAME
|
|
301
|
+
CP_TITLE = _CP_TITLE
|
|
302
|
+
CP_HEADER = _CP_HEADER
|
|
303
|
+
CP_ITEM = _CP_ITEM
|
|
304
|
+
CP_SELECTED = _CP_SELECTED
|
|
305
|
+
CP_HINT = _CP_HINT
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _init_tui_colors() -> None:
|
|
309
|
+
"""Set up curses color pairs for the TUI menus. Non-fatal: a terminal
|
|
310
|
+
without color support gets a monochrome TUI instead of a dead one."""
|
|
311
|
+
try:
|
|
312
|
+
curses.start_color()
|
|
313
|
+
curses.use_default_colors()
|
|
314
|
+
curses.init_pair(_CP_FRAME, curses.COLOR_CYAN, -1)
|
|
315
|
+
curses.init_pair(_CP_TITLE, curses.COLOR_WHITE, -1)
|
|
316
|
+
curses.init_pair(_CP_HEADER, curses.COLOR_YELLOW, -1)
|
|
317
|
+
curses.init_pair(_CP_ITEM, curses.COLOR_WHITE, -1)
|
|
318
|
+
curses.init_pair(_CP_SELECTED, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
|
319
|
+
curses.init_pair(_CP_HINT, curses.COLOR_WHITE, -1)
|
|
320
|
+
except curses.error:
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _curs_set(visibility: int) -> None:
|
|
325
|
+
"""curs_set raises on terminals without cursor-visibility support; the
|
|
326
|
+
cursor is cosmetic, so never let it kill a widget."""
|
|
327
|
+
try:
|
|
328
|
+
curses.curs_set(visibility)
|
|
329
|
+
except curses.error:
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
_TUI_BOX_W = 46
|
|
334
|
+
_TUI_INNER = _TUI_BOX_W - 2 # chars between the two ║ borders
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class ProgressBox:
|
|
338
|
+
"""Curses progress box matching the TUI style, with a tqdm-like API.
|
|
339
|
+
|
|
340
|
+
Draws into the persistent session screen when an interactive session owns
|
|
341
|
+
one; without one it prints plain carriage-returned text lines instead of
|
|
342
|
+
starting a screen of its own, so pipes and redirects stay clean. Redraws
|
|
343
|
+
are throttled — a full-screen erase per item on a 100k-item scan is visible
|
|
344
|
+
flicker and wasted work — and the final update always draws. Progress is
|
|
345
|
+
cosmetic: every draw failure is swallowed, never fatal to the mode.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
_MIN_REDRAW_S = 0.1
|
|
349
|
+
|
|
350
|
+
def __init__(self, total: int, desc: str = ""):
|
|
351
|
+
self.total = max(0, int(total))
|
|
352
|
+
self.desc = desc
|
|
353
|
+
self.current = 0
|
|
354
|
+
self._last_draw = 0.0
|
|
355
|
+
self._closed = False
|
|
356
|
+
self.draw()
|
|
357
|
+
|
|
358
|
+
def set_description(self, desc: str) -> None:
|
|
359
|
+
"""tqdm-parity alias for changing the header text mid-run."""
|
|
360
|
+
self.desc = desc
|
|
361
|
+
self.draw()
|
|
362
|
+
|
|
363
|
+
def update(self, n: int = 1) -> None:
|
|
364
|
+
if self._closed:
|
|
365
|
+
return
|
|
366
|
+
self.current += n
|
|
367
|
+
if (
|
|
368
|
+
self.current >= self.total
|
|
369
|
+
or time.monotonic() - self._last_draw >= self._MIN_REDRAW_S
|
|
370
|
+
):
|
|
371
|
+
self.draw()
|
|
372
|
+
|
|
373
|
+
def close(self) -> None:
|
|
374
|
+
"""Release the display. The session screen is the session's to tear
|
|
375
|
+
down (the next menu redraw erases the box); the text fallback just
|
|
376
|
+
ends its in-place line."""
|
|
377
|
+
if self._closed:
|
|
378
|
+
return
|
|
379
|
+
self._closed = True
|
|
380
|
+
if _SCREEN is None and sys.stdout.isatty():
|
|
381
|
+
try:
|
|
382
|
+
sys.stdout.write("\n")
|
|
383
|
+
sys.stdout.flush()
|
|
384
|
+
except OSError:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
def __enter__(self) -> Self:
|
|
388
|
+
return self
|
|
389
|
+
|
|
390
|
+
def __exit__(self, *exc) -> None:
|
|
391
|
+
self.close()
|
|
392
|
+
|
|
393
|
+
def _text_line(self) -> str:
|
|
394
|
+
percent = self.current / max(1, self.total)
|
|
395
|
+
bar_len = 30
|
|
396
|
+
filled = int(bar_len * percent)
|
|
397
|
+
bar = "█" * filled + "░" * (bar_len - filled)
|
|
398
|
+
return (
|
|
399
|
+
f"{self.desc}: |{bar}| {self.current}/{self.total} ({percent * 100:.0f}%)"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def draw(self) -> None:
|
|
403
|
+
self._last_draw = time.monotonic()
|
|
404
|
+
scr = _SCREEN
|
|
405
|
+
try:
|
|
406
|
+
if scr is not None:
|
|
407
|
+
self._draw_curses(scr)
|
|
408
|
+
elif sys.stdout.isatty():
|
|
409
|
+
end = "\n" if self.current >= self.total else ""
|
|
410
|
+
sys.stdout.write("\r" + self._text_line() + end)
|
|
411
|
+
sys.stdout.flush()
|
|
412
|
+
except Exception:
|
|
413
|
+
# curses.error, or curses missing entirely: progress is cosmetic.
|
|
414
|
+
pass
|
|
415
|
+
|
|
416
|
+
def _draw_curses(self, s) -> None:
|
|
417
|
+
import curses
|
|
418
|
+
|
|
419
|
+
box_w = _TUI_BOX_W
|
|
420
|
+
inner = box_w - 2
|
|
421
|
+
bx = max(0, (s.getmaxyx()[1] - box_w) // 2)
|
|
422
|
+
y = max(0, (s.getmaxyx()[0] - 6) // 2)
|
|
423
|
+
fa = curses.color_pair(_CP_FRAME)
|
|
424
|
+
|
|
425
|
+
s.erase()
|
|
426
|
+
_safe_addstr(s, y, bx, "╔" + "═" * inner + "╗", fa)
|
|
427
|
+
_safe_addstr(s, y + 1, bx, "║", fa)
|
|
428
|
+
_safe_addstr(
|
|
429
|
+
s,
|
|
430
|
+
y + 1,
|
|
431
|
+
bx + 1,
|
|
432
|
+
f" {self.desc}".ljust(inner),
|
|
433
|
+
curses.color_pair(_CP_HEADER) | curses.A_BOLD,
|
|
434
|
+
)
|
|
435
|
+
_safe_addstr(s, y + 1, bx + box_w - 1, "║", fa)
|
|
436
|
+
_safe_addstr(s, y + 2, bx, "╠" + "═" * inner + "╣", fa)
|
|
437
|
+
|
|
438
|
+
percent = self.current / max(1, self.total)
|
|
439
|
+
bar_len = inner - 10
|
|
440
|
+
filled = int(bar_len * percent)
|
|
441
|
+
bar = "█" * filled + "░" * (bar_len - filled)
|
|
442
|
+
pct_str = f"{int(percent * 100):3d}%"
|
|
443
|
+
|
|
444
|
+
_safe_addstr(s, y + 3, bx, "║", fa)
|
|
445
|
+
_safe_addstr(
|
|
446
|
+
s,
|
|
447
|
+
y + 3,
|
|
448
|
+
bx + 1,
|
|
449
|
+
f" {bar} {pct_str} ".ljust(inner),
|
|
450
|
+
curses.color_pair(_CP_ITEM),
|
|
451
|
+
)
|
|
452
|
+
_safe_addstr(s, y + 3, bx + box_w - 1, "║", fa)
|
|
453
|
+
info = f" {self.current}/{self.total} · Ctrl-C cancels"
|
|
454
|
+
_safe_addstr(s, y + 4, bx, "║", fa)
|
|
455
|
+
_safe_addstr(
|
|
456
|
+
s,
|
|
457
|
+
y + 4,
|
|
458
|
+
bx + 1,
|
|
459
|
+
info[:inner].ljust(inner),
|
|
460
|
+
curses.color_pair(_CP_ITEM),
|
|
461
|
+
)
|
|
462
|
+
_safe_addstr(s, y + 4, bx + box_w - 1, "║", fa)
|
|
463
|
+
_safe_addstr(s, y + 5, bx, "╚" + "═" * inner + "╝", fa)
|
|
464
|
+
s.refresh()
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def progress_box(total: int, desc: str = "") -> ProgressBox:
|
|
468
|
+
"""Factory matching tqdm's ``(total, desc)`` shape for drop-in use::
|
|
469
|
+
|
|
470
|
+
with vir_tui.progress_box(len(items), "Scanning") as bar:
|
|
471
|
+
for item in items:
|
|
472
|
+
...
|
|
473
|
+
bar.update()
|
|
474
|
+
"""
|
|
475
|
+
return ProgressBox(total, desc)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _safe_addstr(stdscr, y: int, x: int, text: str, attr: int) -> None:
|
|
479
|
+
"""Write to curses screen, silently ignoring out-of-bounds errors."""
|
|
480
|
+
try:
|
|
481
|
+
stdscr.addstr(y, x, text, attr)
|
|
482
|
+
except curses.error:
|
|
483
|
+
pass
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _tui_select(
|
|
487
|
+
title: str,
|
|
488
|
+
sections: list,
|
|
489
|
+
hints: str = "\u2191\u2193 Navigate \u23ce Select q Quit",
|
|
490
|
+
) -> tuple | None:
|
|
491
|
+
"""Full-screen arrow-key menu using curses."""
|
|
492
|
+
BOX_W = _TUI_BOX_W
|
|
493
|
+
INNER = _TUI_INNER
|
|
494
|
+
|
|
495
|
+
flat: list[tuple[int, int]] = []
|
|
496
|
+
for si, (_, items) in enumerate(sections):
|
|
497
|
+
for ii in range(len(items)):
|
|
498
|
+
flat.append((si, ii))
|
|
499
|
+
|
|
500
|
+
def _draw(stdscr, cur: int) -> None:
|
|
501
|
+
stdscr.erase()
|
|
502
|
+
h, w = stdscr.getmaxyx()
|
|
503
|
+
bx = max(0, (w - BOX_W) // 2)
|
|
504
|
+
fa = curses.color_pair(_CP_FRAME)
|
|
505
|
+
|
|
506
|
+
box_h = 3
|
|
507
|
+
sel_row = 3 # offset of the selected item from the box top
|
|
508
|
+
idx0 = 0
|
|
509
|
+
for si, (hdr, items) in enumerate(sections):
|
|
510
|
+
if si > 0:
|
|
511
|
+
box_h += 1
|
|
512
|
+
if hdr:
|
|
513
|
+
box_h += 1
|
|
514
|
+
if idx0 <= cur < idx0 + len(items):
|
|
515
|
+
sel_row = box_h + (cur - idx0)
|
|
516
|
+
idx0 += len(items)
|
|
517
|
+
box_h += len(items)
|
|
518
|
+
box_h += 1
|
|
519
|
+
|
|
520
|
+
y = max(0, (h - box_h - 2) // 2)
|
|
521
|
+
if y + sel_row >= h - 1:
|
|
522
|
+
# Terminal shorter than the menu: shift the box up so the selected
|
|
523
|
+
# row stays visible (rows scrolled off the top just don't draw).
|
|
524
|
+
y = (h - 2) - sel_row
|
|
525
|
+
|
|
526
|
+
_safe_addstr(stdscr, y, bx, "\u2554" + "\u2550" * INNER + "\u2557", fa)
|
|
527
|
+
y += 1
|
|
528
|
+
|
|
529
|
+
_safe_addstr(stdscr, y, bx, "\u2551", fa)
|
|
530
|
+
_safe_addstr(
|
|
531
|
+
stdscr,
|
|
532
|
+
y,
|
|
533
|
+
bx + 1,
|
|
534
|
+
f" {title:^{INNER - 2}} ",
|
|
535
|
+
curses.color_pair(_CP_TITLE) | curses.A_BOLD,
|
|
536
|
+
)
|
|
537
|
+
_safe_addstr(stdscr, y, bx + BOX_W - 1, "\u2551", fa)
|
|
538
|
+
y += 1
|
|
539
|
+
|
|
540
|
+
_safe_addstr(stdscr, y, bx, "\u2560" + "\u2550" * INNER + "\u2563", fa)
|
|
541
|
+
y += 1
|
|
542
|
+
|
|
543
|
+
idx = 0
|
|
544
|
+
for si, (hdr, items) in enumerate(sections):
|
|
545
|
+
if si > 0:
|
|
546
|
+
_safe_addstr(stdscr, y, bx, "\u255f" + "\u2500" * INNER + "\u2562", fa)
|
|
547
|
+
y += 1
|
|
548
|
+
|
|
549
|
+
if hdr:
|
|
550
|
+
content = f" {hdr}" + " " * (INNER - len(hdr) - 2)
|
|
551
|
+
_safe_addstr(stdscr, y, bx, "\u2551", fa)
|
|
552
|
+
_safe_addstr(
|
|
553
|
+
stdscr,
|
|
554
|
+
y,
|
|
555
|
+
bx + 1,
|
|
556
|
+
content,
|
|
557
|
+
curses.color_pair(_CP_HEADER) | curses.A_BOLD,
|
|
558
|
+
)
|
|
559
|
+
_safe_addstr(stdscr, y, bx + BOX_W - 1, "\u2551", fa)
|
|
560
|
+
y += 1
|
|
561
|
+
|
|
562
|
+
for _ii, label in enumerate(items):
|
|
563
|
+
is_sel = idx == cur
|
|
564
|
+
if is_sel:
|
|
565
|
+
text = f" \u25ba {label}"
|
|
566
|
+
attr = curses.color_pair(_CP_SELECTED) | curses.A_BOLD
|
|
567
|
+
else:
|
|
568
|
+
text = f" {label}"
|
|
569
|
+
attr = curses.color_pair(_CP_ITEM)
|
|
570
|
+
padded = text + " " * max(0, INNER - len(text))
|
|
571
|
+
_safe_addstr(stdscr, y, bx, "\u2551", fa)
|
|
572
|
+
_safe_addstr(stdscr, y, bx + 1, padded[:INNER], attr)
|
|
573
|
+
_safe_addstr(stdscr, y, bx + BOX_W - 1, "\u2551", fa)
|
|
574
|
+
y += 1
|
|
575
|
+
idx += 1
|
|
576
|
+
|
|
577
|
+
_safe_addstr(stdscr, y, bx, "\u255a" + "\u2550" * INNER + "\u255d", fa)
|
|
578
|
+
y += 2
|
|
579
|
+
|
|
580
|
+
hx = max(0, (w - len(hints)) // 2)
|
|
581
|
+
_safe_addstr(stdscr, y, hx, hints, curses.color_pair(_CP_HINT) | curses.A_DIM)
|
|
582
|
+
|
|
583
|
+
stdscr.refresh()
|
|
584
|
+
|
|
585
|
+
def _run(stdscr) -> tuple | None:
|
|
586
|
+
_curs_set(0)
|
|
587
|
+
cur = 0
|
|
588
|
+
while True:
|
|
589
|
+
_draw(stdscr, cur)
|
|
590
|
+
key = stdscr.get_wch()
|
|
591
|
+
if key in (curses.KEY_UP, "k"):
|
|
592
|
+
cur = (cur - 1) % len(flat)
|
|
593
|
+
elif key in (curses.KEY_DOWN, "j"):
|
|
594
|
+
cur = (cur + 1) % len(flat)
|
|
595
|
+
elif key in (curses.KEY_ENTER, 10, 13, "\n", "\r"):
|
|
596
|
+
return flat[cur]
|
|
597
|
+
elif key in ("q", "Q", 27, "\x1b"):
|
|
598
|
+
return None
|
|
599
|
+
elif key == curses.KEY_RESIZE:
|
|
600
|
+
pass
|
|
601
|
+
|
|
602
|
+
try:
|
|
603
|
+
return _with_screen(_run)
|
|
604
|
+
except curses.error:
|
|
605
|
+
# A real curses failure (dumb terminal, TERM=vt100), not a user Quit:
|
|
606
|
+
# degrade the whole session to the text fallback and hand the menu
|
|
607
|
+
# loop a sentinel it re-enters on, instead of silently exiting 0.
|
|
608
|
+
_degrade_to_text()
|
|
609
|
+
return "fallback"
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _tui_prompt_str(label: str, default: str | None) -> str | None:
|
|
613
|
+
"""Boxed single-line prompt. Enter accepts (bare Enter = the default);
|
|
614
|
+
Esc cancels and returns None; Ctrl-U clears the field."""
|
|
615
|
+
BOX_W = _TUI_BOX_W
|
|
616
|
+
INNER = _TUI_INNER
|
|
617
|
+
|
|
618
|
+
def _run(stdscr) -> str | None:
|
|
619
|
+
_curs_set(1)
|
|
620
|
+
buf = list(default or "")
|
|
621
|
+
|
|
622
|
+
while True:
|
|
623
|
+
stdscr.erase()
|
|
624
|
+
h, w = stdscr.getmaxyx()
|
|
625
|
+
bx = max(0, (w - BOX_W) // 2)
|
|
626
|
+
fa = curses.color_pair(_CP_FRAME)
|
|
627
|
+
|
|
628
|
+
y = max(0, (h - 8) // 2)
|
|
629
|
+
|
|
630
|
+
_safe_addstr(stdscr, y, bx, "\u2554" + "\u2550" * INNER + "\u2557", fa)
|
|
631
|
+
y += 1
|
|
632
|
+
|
|
633
|
+
lbl = f" {label}"
|
|
634
|
+
padded_lbl = lbl + " " * max(0, INNER - len(lbl))
|
|
635
|
+
_safe_addstr(stdscr, y, bx, "\u2551", fa)
|
|
636
|
+
_safe_addstr(
|
|
637
|
+
stdscr,
|
|
638
|
+
y,
|
|
639
|
+
bx + 1,
|
|
640
|
+
padded_lbl[:INNER],
|
|
641
|
+
curses.color_pair(_CP_HEADER) | curses.A_BOLD,
|
|
642
|
+
)
|
|
643
|
+
_safe_addstr(stdscr, y, bx + BOX_W - 1, "\u2551", fa)
|
|
644
|
+
y += 1
|
|
645
|
+
|
|
646
|
+
_safe_addstr(stdscr, y, bx, "\u255f" + "\u2500" * INNER + "\u2562", fa)
|
|
647
|
+
y += 1
|
|
648
|
+
|
|
649
|
+
display = "".join(buf)
|
|
650
|
+
max_input = INNER - 4
|
|
651
|
+
if len(display) > max_input:
|
|
652
|
+
visible = "\u2026" + display[-(max_input - 1) :]
|
|
653
|
+
else:
|
|
654
|
+
visible = display
|
|
655
|
+
input_text = f" > {visible}" + " " * max(0, INNER - len(visible) - 3)
|
|
656
|
+
_safe_addstr(stdscr, y, bx, "\u2551", fa)
|
|
657
|
+
_safe_addstr(
|
|
658
|
+
stdscr, y, bx + 1, input_text[:INNER], curses.color_pair(_CP_ITEM)
|
|
659
|
+
)
|
|
660
|
+
_safe_addstr(stdscr, y, bx + BOX_W - 1, "\u2551", fa)
|
|
661
|
+
input_y = y
|
|
662
|
+
y += 1
|
|
663
|
+
|
|
664
|
+
_safe_addstr(stdscr, y, bx, "\u255a" + "\u2550" * INNER + "\u255d", fa)
|
|
665
|
+
y += 2
|
|
666
|
+
|
|
667
|
+
hints = "\u23ce Accept Esc Cancel Ctrl-U Clear"
|
|
668
|
+
hx = max(0, (w - len(hints)) // 2)
|
|
669
|
+
_safe_addstr(
|
|
670
|
+
stdscr, y, hx, hints, curses.color_pair(_CP_HINT) | curses.A_DIM
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
cursor_x = bx + 4 + min(len(display), max_input)
|
|
674
|
+
try:
|
|
675
|
+
stdscr.move(input_y, min(cursor_x, bx + BOX_W - 2))
|
|
676
|
+
except curses.error:
|
|
677
|
+
pass
|
|
678
|
+
stdscr.refresh()
|
|
679
|
+
|
|
680
|
+
key = stdscr.get_wch()
|
|
681
|
+
if key in (curses.KEY_ENTER, 10, 13, "\n", "\r"):
|
|
682
|
+
result = "".join(buf).strip()
|
|
683
|
+
return result if result else (default or "")
|
|
684
|
+
elif key in (27, "\x1b"):
|
|
685
|
+
return None # Esc cancels; it must never launch with defaults
|
|
686
|
+
elif key in (curses.KEY_BACKSPACE, 127, 8, "\x7f", "\x08"):
|
|
687
|
+
if buf:
|
|
688
|
+
buf.pop()
|
|
689
|
+
elif key in (21, "\x15"): # Ctrl-U: clear the field (pre-filled defaults)
|
|
690
|
+
buf.clear()
|
|
691
|
+
elif key == curses.KEY_RESIZE:
|
|
692
|
+
pass
|
|
693
|
+
elif isinstance(key, str) and key.isprintable():
|
|
694
|
+
buf.append(key)
|
|
695
|
+
|
|
696
|
+
try:
|
|
697
|
+
return _with_screen(_run)
|
|
698
|
+
except KeyboardInterrupt:
|
|
699
|
+
return None # Ctrl-C at a prompt cancels, exactly like Esc
|
|
700
|
+
except curses.error:
|
|
701
|
+
# _USE_CURSES is now False, so this re-asks via the text prompt.
|
|
702
|
+
_degrade_to_text()
|
|
703
|
+
return _prompt_str(label, default)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _tui_pause() -> None:
|
|
707
|
+
BOX_W = _TUI_BOX_W
|
|
708
|
+
INNER = _TUI_INNER
|
|
709
|
+
|
|
710
|
+
def _run(stdscr) -> None:
|
|
711
|
+
_curs_set(0)
|
|
712
|
+
|
|
713
|
+
stdscr.erase()
|
|
714
|
+
h, w = stdscr.getmaxyx()
|
|
715
|
+
bx = max(0, (w - BOX_W) // 2)
|
|
716
|
+
fa = curses.color_pair(_CP_FRAME)
|
|
717
|
+
|
|
718
|
+
y = max(0, (h - 5) // 2)
|
|
719
|
+
|
|
720
|
+
_safe_addstr(stdscr, y, bx, "\u2554" + "\u2550" * INNER + "\u2557", fa)
|
|
721
|
+
y += 1
|
|
722
|
+
|
|
723
|
+
msg = "Press Enter to continue\u2026"
|
|
724
|
+
padded = f" {msg:^{INNER - 2}} "
|
|
725
|
+
_safe_addstr(stdscr, y, bx, "\u2551", fa)
|
|
726
|
+
_safe_addstr(
|
|
727
|
+
stdscr,
|
|
728
|
+
y,
|
|
729
|
+
bx + 1,
|
|
730
|
+
padded[:INNER],
|
|
731
|
+
curses.color_pair(_CP_TITLE) | curses.A_BOLD,
|
|
732
|
+
)
|
|
733
|
+
_safe_addstr(stdscr, y, bx + BOX_W - 1, "\u2551", fa)
|
|
734
|
+
y += 1
|
|
735
|
+
|
|
736
|
+
_safe_addstr(stdscr, y, bx, "\u255a" + "\u2550" * INNER + "\u255d", fa)
|
|
737
|
+
stdscr.refresh()
|
|
738
|
+
|
|
739
|
+
while True:
|
|
740
|
+
key = stdscr.get_wch()
|
|
741
|
+
if key in (curses.KEY_ENTER, 10, 13, "\n", "\r", "q", "Q", 27, "\x1b"):
|
|
742
|
+
return
|
|
743
|
+
|
|
744
|
+
try:
|
|
745
|
+
_with_screen(_run)
|
|
746
|
+
except KeyboardInterrupt:
|
|
747
|
+
pass
|
|
748
|
+
except curses.error:
|
|
749
|
+
# _USE_CURSES is now False, so this re-runs as the text pause.
|
|
750
|
+
_degrade_to_text()
|
|
751
|
+
_pause()
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def fallback_input(prompt: str, mapping: dict) -> Any:
|
|
755
|
+
# KeyboardInterrupt propagates on purpose: Ctrl-C at the menu must exit
|
|
756
|
+
# 130 like the curses menu does, not read as a clean Quit.
|
|
757
|
+
try:
|
|
758
|
+
ch = input(prompt).strip().lower()
|
|
759
|
+
except EOFError:
|
|
760
|
+
print()
|
|
761
|
+
return None # input exhausted: treat as Quit
|
|
762
|
+
return mapping.get(ch, "invalid")
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def build_fallback(sections, aliases=None, letter_keys=None):
|
|
766
|
+
aliases = aliases or {}
|
|
767
|
+
letter_keys = letter_keys or {}
|
|
768
|
+
mapping = dict(aliases)
|
|
769
|
+
display = []
|
|
770
|
+
n = 0
|
|
771
|
+
for si, (hdr, items) in enumerate(sections):
|
|
772
|
+
rows = []
|
|
773
|
+
for ii, label in enumerate(items):
|
|
774
|
+
clean = " ".join(label.split())
|
|
775
|
+
letter = letter_keys.get(clean)
|
|
776
|
+
if letter is not None:
|
|
777
|
+
key, target = letter
|
|
778
|
+
rows.append(f"{key}) {clean}")
|
|
779
|
+
mapping[key] = (si, ii) if target == "self" else target
|
|
780
|
+
else:
|
|
781
|
+
n += 1
|
|
782
|
+
rows.append(f"{n}) {clean}")
|
|
783
|
+
mapping[str(n)] = (si, ii)
|
|
784
|
+
display.append((hdr, rows))
|
|
785
|
+
return display, mapping, n
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def tui_select(
|
|
789
|
+
title,
|
|
790
|
+
sections,
|
|
791
|
+
hints="↑↓ Navigate ⏎ Select q Quit",
|
|
792
|
+
aliases=None,
|
|
793
|
+
letter_keys=None,
|
|
794
|
+
):
|
|
795
|
+
if _USE_CURSES:
|
|
796
|
+
res = _tui_select(title, sections, hints=hints)
|
|
797
|
+
if res != "fallback":
|
|
798
|
+
return res
|
|
799
|
+
# Fallback
|
|
800
|
+
display, mapping, max_n = build_fallback(sections, aliases, letter_keys)
|
|
801
|
+
box_menu(title, display)
|
|
802
|
+
return fallback_input(f" Select [1-{max_n}/q]: ", mapping)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _match_lines(
|
|
806
|
+
lines: list[str], query: str, start: int = 0, reverse: bool = False
|
|
807
|
+
) -> int | None:
|
|
808
|
+
"""Index of the first line containing ``query`` (case-insensitive),
|
|
809
|
+
searching forward from ``start`` — or backward when ``reverse`` — and
|
|
810
|
+
wrapping around once. None when there is no match. A pure helper so
|
|
811
|
+
pager search is testable without a curses session."""
|
|
812
|
+
q = query.casefold()
|
|
813
|
+
if not q or not lines:
|
|
814
|
+
return None
|
|
815
|
+
n = len(lines)
|
|
816
|
+
start = max(0, min(start, n - 1))
|
|
817
|
+
if reverse:
|
|
818
|
+
order = list(range(start, -1, -1)) + list(range(n - 1, start, -1))
|
|
819
|
+
else:
|
|
820
|
+
order = list(range(start, n)) + list(range(start))
|
|
821
|
+
for i in order:
|
|
822
|
+
if q in lines[i].casefold():
|
|
823
|
+
return i
|
|
824
|
+
return None
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def tui_page(title: str, content: str) -> None:
|
|
828
|
+
if not _USE_CURSES:
|
|
829
|
+
print(content)
|
|
830
|
+
_pause()
|
|
831
|
+
return
|
|
832
|
+
|
|
833
|
+
lines = content.replace("\x00", "").expandtabs(4).split("\n")
|
|
834
|
+
# Computed once, not per keypress: the content never changes while paging.
|
|
835
|
+
max_line_len = max((len(ln) for ln in lines), default=0)
|
|
836
|
+
|
|
837
|
+
def _run(stdscr):
|
|
838
|
+
_curs_set(0)
|
|
839
|
+
top = 0
|
|
840
|
+
left = 0
|
|
841
|
+
query = ""
|
|
842
|
+
while True:
|
|
843
|
+
stdscr.erase()
|
|
844
|
+
h, w = stdscr.getmaxyx()
|
|
845
|
+
fa = curses.color_pair(_CP_FRAME)
|
|
846
|
+
|
|
847
|
+
# Width follows the longest line (up to the terminal width) so wide
|
|
848
|
+
# reports — long duplicate paths, say — are not chopped at 80 columns.
|
|
849
|
+
content_w = min(w, max(_TUI_BOX_W, max_line_len + 4))
|
|
850
|
+
bx = max(0, (w - content_w) // 2)
|
|
851
|
+
max_lines = max(1, h - 3)
|
|
852
|
+
last_top = max(0, len(lines) - max_lines)
|
|
853
|
+
top = min(top, last_top) # keep the view valid across resizes
|
|
854
|
+
visible_w = max(1, content_w - 4)
|
|
855
|
+
max_left = max(0, max_line_len - visible_w)
|
|
856
|
+
left = min(left, max_left)
|
|
857
|
+
|
|
858
|
+
# Title on the top border, hints on the last row; content fills the
|
|
859
|
+
# full height between them.
|
|
860
|
+
_safe_addstr(stdscr, 0, bx, "╔" + "═" * (content_w - 2) + "╗", fa)
|
|
861
|
+
_safe_addstr(
|
|
862
|
+
stdscr,
|
|
863
|
+
0,
|
|
864
|
+
bx + 2,
|
|
865
|
+
f" {title} ",
|
|
866
|
+
curses.color_pair(_CP_TITLE) | curses.A_BOLD,
|
|
867
|
+
)
|
|
868
|
+
_safe_addstr(stdscr, h - 2, bx, "╚" + "═" * (content_w - 2) + "╝", fa)
|
|
869
|
+
|
|
870
|
+
hints = (
|
|
871
|
+
"↑↓ Scroll ←→ Pan / Search n/N Match g/G Top/Bottom q/Esc Close"
|
|
872
|
+
)
|
|
873
|
+
_safe_addstr(
|
|
874
|
+
stdscr,
|
|
875
|
+
h - 1,
|
|
876
|
+
max(0, (w - len(hints)) // 2),
|
|
877
|
+
hints,
|
|
878
|
+
curses.color_pair(_CP_HINT) | curses.A_DIM,
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
for i in range(max_lines):
|
|
882
|
+
_safe_addstr(stdscr, i + 1, bx, "║", fa)
|
|
883
|
+
if top + i < len(lines):
|
|
884
|
+
ln = lines[top + i]
|
|
885
|
+
seg = ln[left : left + visible_w]
|
|
886
|
+
# Ellipsis markers show that a line continues off-screen.
|
|
887
|
+
if len(ln) - left > visible_w and seg:
|
|
888
|
+
seg = seg[:-1] + "…"
|
|
889
|
+
if left and seg:
|
|
890
|
+
seg = "…" + seg[1:]
|
|
891
|
+
_safe_addstr(
|
|
892
|
+
stdscr,
|
|
893
|
+
i + 1,
|
|
894
|
+
bx + 2,
|
|
895
|
+
seg,
|
|
896
|
+
curses.color_pair(_CP_ITEM),
|
|
897
|
+
)
|
|
898
|
+
_safe_addstr(stdscr, i + 1, bx + content_w - 1, "║", fa)
|
|
899
|
+
|
|
900
|
+
stdscr.refresh()
|
|
901
|
+
|
|
902
|
+
key = stdscr.get_wch()
|
|
903
|
+
if key in (curses.KEY_UP, "k"):
|
|
904
|
+
top = max(0, top - 1)
|
|
905
|
+
elif key in (curses.KEY_DOWN, "j"):
|
|
906
|
+
top = min(last_top, top + 1)
|
|
907
|
+
elif key in (curses.KEY_LEFT, "h"):
|
|
908
|
+
left = max(0, left - 8)
|
|
909
|
+
elif key in (curses.KEY_RIGHT, "l"):
|
|
910
|
+
left = min(max_left, left + 8)
|
|
911
|
+
elif key == curses.KEY_PPAGE:
|
|
912
|
+
top = max(0, top - max_lines)
|
|
913
|
+
elif key == curses.KEY_NPAGE:
|
|
914
|
+
top = min(last_top, top + max_lines)
|
|
915
|
+
elif key in (curses.KEY_HOME, "g"):
|
|
916
|
+
top = 0
|
|
917
|
+
left = 0
|
|
918
|
+
elif key in (curses.KEY_END, "G"):
|
|
919
|
+
top = last_top
|
|
920
|
+
elif key == "/":
|
|
921
|
+
got = _tui_prompt_str("Search", query)
|
|
922
|
+
if got is not None and got.strip():
|
|
923
|
+
query = got.strip()
|
|
924
|
+
hit = _match_lines(lines, query, top)
|
|
925
|
+
if hit is not None:
|
|
926
|
+
top = hit
|
|
927
|
+
elif key == "n" and query:
|
|
928
|
+
hit = _match_lines(lines, query, min(top + 1, len(lines) - 1))
|
|
929
|
+
if hit is not None:
|
|
930
|
+
top = hit
|
|
931
|
+
elif key == "N" and query:
|
|
932
|
+
hit = _match_lines(lines, query, max(top - 1, 0), reverse=True)
|
|
933
|
+
if hit is not None:
|
|
934
|
+
top = hit
|
|
935
|
+
elif key in ("q", "Q", 27, "\x1b", curses.KEY_ENTER, 10, 13, "\n", "\r"):
|
|
936
|
+
break
|
|
937
|
+
elif key == curses.KEY_RESIZE:
|
|
938
|
+
pass
|
|
939
|
+
|
|
940
|
+
try:
|
|
941
|
+
_with_screen(_run)
|
|
942
|
+
except KeyboardInterrupt:
|
|
943
|
+
pass # Ctrl-C just closes the pager
|
|
944
|
+
except curses.error:
|
|
945
|
+
_degrade_to_text()
|
|
946
|
+
print(content)
|
|
947
|
+
_pause()
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
@contextmanager
|
|
951
|
+
def capture_output():
|
|
952
|
+
old_out, old_err = sys.stdout, sys.stderr
|
|
953
|
+
out, err = io.StringIO(), io.StringIO()
|
|
954
|
+
sys.stdout, sys.stderr = out, err
|
|
955
|
+
try:
|
|
956
|
+
yield out, err
|
|
957
|
+
finally:
|
|
958
|
+
sys.stdout, sys.stderr = old_out, old_err
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def run_with_capture(title: str, func, *args, footer: str = "", **kwargs):
|
|
962
|
+
result = None
|
|
963
|
+
note = ""
|
|
964
|
+
with capture_output() as (out, err):
|
|
965
|
+
try:
|
|
966
|
+
result = func(*args, **kwargs)
|
|
967
|
+
except KeyboardInterrupt:
|
|
968
|
+
note = "[Cancelled]"
|
|
969
|
+
except Exception:
|
|
970
|
+
# A mode error must not escape as a raw traceback with the screen
|
|
971
|
+
# stuck in curses mode; page it (plus whatever was captured).
|
|
972
|
+
note = "[Error]\n" + traceback.format_exc().rstrip()
|
|
973
|
+
# With a session screen the mode's _TUIPbar drew into it and nothing needs
|
|
974
|
+
# tearing down. Without one (direct invocation) the pbar initscr()'d a
|
|
975
|
+
# screen of its own; end it before paging, even (especially) when the mode
|
|
976
|
+
# died mid-run.
|
|
977
|
+
if _SCREEN is None:
|
|
978
|
+
if _USE_CURSES:
|
|
979
|
+
try:
|
|
980
|
+
if not curses.isendwin():
|
|
981
|
+
curses.endwin()
|
|
982
|
+
except curses.error:
|
|
983
|
+
pass
|
|
984
|
+
reset_terminal()
|
|
985
|
+
|
|
986
|
+
text = ""
|
|
987
|
+
if note:
|
|
988
|
+
text += note + "\n"
|
|
989
|
+
if isinstance(result, str) and result:
|
|
990
|
+
text += result + "\n"
|
|
991
|
+
|
|
992
|
+
out_text = out.getvalue().strip()
|
|
993
|
+
if out_text:
|
|
994
|
+
text += out_text + "\n"
|
|
995
|
+
|
|
996
|
+
err_text = err.getvalue().strip()
|
|
997
|
+
if err_text:
|
|
998
|
+
text += "\n[Errors/Warnings]:\n" + err_text + "\n"
|
|
999
|
+
|
|
1000
|
+
if footer and not note:
|
|
1001
|
+
# The "Report written to ..." footer must not assert a file exists
|
|
1002
|
+
# when the mode died or was cancelled before finishing.
|
|
1003
|
+
text += "\n" + footer + "\n"
|
|
1004
|
+
|
|
1005
|
+
text = text.strip()
|
|
1006
|
+
if text:
|
|
1007
|
+
tui_page(title, text)
|
|
1008
|
+
else:
|
|
1009
|
+
_pause()
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def reset_terminal() -> None:
|
|
1013
|
+
if _SCREEN is not None:
|
|
1014
|
+
return
|
|
1015
|
+
if not sys.stdin.isatty():
|
|
1016
|
+
return
|
|
1017
|
+
try:
|
|
1018
|
+
subprocess.run(["stty", "sane"], stdin=sys.stdin, check=False)
|
|
1019
|
+
except Exception:
|
|
1020
|
+
pass
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vir-tui
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: A lightweight, terminal UI primitive library for the VirInvictus CLI toolchain.
|
|
5
|
+
Author: Brandon LaRocque
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# vir-tui
|
|
11
|
+
|
|
12
|
+
A lightweight, terminal UI primitive library for the VirInvictus CLI toolchain.
|
|
13
|
+
|
|
14
|
+
Provides a raw TTY event loop, a grid-based menu renderer, robust cross-platform ANSI colors, input prompt lifecycles, and a fallback progress bar wrapper for CLI applications that run headless but offer an interactive terminal interface.
|
|
15
|
+
|
|
16
|
+
Powers [CalibreQuarry](https://github.com/VirInvictus/CalibreQuarry) and [Lattice](https://github.com/VirInvictus/Lattice).
|
|
17
|
+
|
|
18
|
+
`Python · stdlib`
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- **Menus**: full-screen arrow-key `tui_select` (sections, aliases, letter keys) with an automatic numbered text fallback when curses is unavailable; a scrollable, pannable results pager (`tui_page`) with `/` search and `n`/`N` match jumping.
|
|
23
|
+
- **Progress**: `progress_box()` — a session-screen-aware curses progress box with a tqdm-like API and a pipe-safe text fallback.
|
|
24
|
+
- **Sessions**: `interactive_session()` context manager owning the curses screen lifecycle (open, degrade, close, KeyboardInterrupt cleanup).
|
|
25
|
+
- **Formatters**: consistent `success`, `info`, `warn`, `error` styling across apps.
|
|
26
|
+
- **Prompts**: `ask`, `ask_yn`, `confirm`, `prompt_int`, `prompt_float`, `prompt_out`, `prompt_path`, plus `out_note` for "where did my report go" footers.
|
|
27
|
+
- **Capture**: `run_with_capture` wrapper for redirecting stdout/stderr into a temporary scrolling buffer while a background task runs, rendering a header/footer on top.
|
|
28
|
+
|
|
29
|
+
## Support
|
|
30
|
+
|
|
31
|
+
If vir-tui's useful to you and you'd like to chip in:
|
|
32
|
+
|
|
33
|
+
- liberapay · [liberapay.com/bdkl](https://liberapay.com/bdkl/)
|
|
34
|
+
- bitcoin
|
|
35
|
+
```
|
|
36
|
+
bc1qkge6zr45tzqfwfmvma2ylumt6mg7wlwmhr05yv
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
GPL-3.0-or-later.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
vir_tui/__init__.py,sha256=CMOYCVr5hTWtgvt14w4vbXoF3azn-oHqzJlwVTJwQWs,715
|
|
2
|
+
vir_tui/core.py,sha256=P-sY4uoYvhgH-6dy3UgFLNKnYhdB5cdK6r-p9qjpi-A,2557
|
|
3
|
+
vir_tui/menu.py,sha256=Tgn_NuGewM3ARpapt4ZNL1FM43SmPvHhP926RfSGhF8,33431
|
|
4
|
+
vir_tui-2.2.0.dist-info/METADATA,sha256=qQAsjVyjL8qUp-J5W1n0Ia5sn4FKgUTLoAyvoBOFQsQ,1901
|
|
5
|
+
vir_tui-2.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
vir_tui-2.2.0.dist-info/licenses/LICENSE,sha256=Sk-yB98_-W28ilV4_PMBrcGf6y3wXuce-rRCdbyhirM,1073
|
|
7
|
+
vir_tui-2.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brandon LaRocque
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|