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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.