@codebend3r/che 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +424 -0
- package/bin/che.py +869 -0
- package/bin/commands.py +872 -0
- package/bin/drives/eject-all-drives.py +203 -0
- package/bin/drives/mount-all-drives.py +140 -0
- package/bin/drives/ping-nas.py +242 -0
- package/bin/files/compress-folders.py +188 -0
- package/bin/files/delete-by-ext.py +108 -0
- package/bin/files/delete-empty-folders.py +136 -0
- package/bin/files/delete-smb-files.py +105 -0
- package/bin/files/files-under-size.py +132 -0
- package/bin/files/find-largest-files.py +102 -0
- package/bin/files/make-alpha-dir.py +63 -0
- package/bin/git/all-actions.py +545 -0
- package/bin/git/checkout-my-branches.py +147 -0
- package/bin/git/clean-stale-branches.py +161 -0
- package/bin/git/prune-worktrees.py +143 -0
- package/bin/git/sync-all-branches.py +368 -0
- package/bin/git/update-local-branches.py +195 -0
- package/bin/install.py +1515 -0
- package/bin/shellgen.py +782 -0
- package/bin/system/btop-launch.py +114 -0
- package/bin/system/update-brew.py +146 -0
- package/bin/tui.py +648 -0
- package/bin/utils.py +836 -0
- package/bin/video/delete-duplicate-videos.py +313 -0
- package/bin/video/detect-green-magenta-videos.py +268 -0
- package/bin/video/find-movie-by-year.py +84 -0
- package/bin/video/find-video-mkv-issues.py +242 -0
- package/bin/video/fix-codecs.py +209 -0
- package/bin/video/largest-tv-shows.py +175 -0
- package/bin/video/remove-metadata.py +126 -0
- package/bin/video/rename-video-file.py +329 -0
- package/bin/video/scan-videos-audio-language.py +110 -0
- package/bin/video/show-codecs.py +111 -0
- package/bin/video/validate-video-files.py +114 -0
- package/bin/video/video-list.py +120 -0
- package/package.json +54 -0
- package/shell/che.bash +382 -0
- package/shell/che.fish +282 -0
- package/shell/che.zsh +389 -0
- package/shell/completions/_che +146 -0
- package/shell/completions/che.bash +107 -0
- package/shell/completions/che.fish +371 -0
package/bin/tui.py
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"""Terminal primitives for the interactive parts of ``che``.
|
|
2
|
+
|
|
3
|
+
Standard library only, like everything else here: no curses windows, no
|
|
4
|
+
third-party TUI toolkit. What that buys is a menu that runs from a bare
|
|
5
|
+
``python3`` on a freshly cloned machine, which is the whole point of this repo.
|
|
6
|
+
|
|
7
|
+
What this module provides:
|
|
8
|
+
|
|
9
|
+
* :class:`Term` - alternate screen, hidden cursor and raw input, restored on
|
|
10
|
+
every exit path including SIGINT, SIGTERM and an uncaught exception.
|
|
11
|
+
* :func:`read_key` - one keypress, decoded into a name (``"up"``, ``"enter"``,
|
|
12
|
+
``"ctrl-c"``) rather than a raw escape sequence.
|
|
13
|
+
* :class:`Screen` - a frame buffer. Frames are composed in memory and written
|
|
14
|
+
with a single ``write()``, which is what keeps the menu from flickering.
|
|
15
|
+
* :func:`display_width` and :func:`fit` - width-aware truncation and padding,
|
|
16
|
+
so a row with an emoji in it still lines up.
|
|
17
|
+
* :func:`ask`, :func:`confirm`, :func:`choose` - line-based prompts with
|
|
18
|
+
readline editing, filename completion and per-prompt history.
|
|
19
|
+
|
|
20
|
+
Library module: no shebang, not executable.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import select
|
|
28
|
+
import signal
|
|
29
|
+
import sys
|
|
30
|
+
import termios
|
|
31
|
+
import tty
|
|
32
|
+
import unicodedata
|
|
33
|
+
from collections.abc import Iterable, Sequence
|
|
34
|
+
from contextlib import suppress
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from types import TracebackType
|
|
38
|
+
|
|
39
|
+
from utils import color_enabled
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Colors
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Palette:
|
|
48
|
+
"""Every escape the UI uses, in one place so NO_COLOR can blank them all."""
|
|
49
|
+
|
|
50
|
+
reset: str = "\033[0m"
|
|
51
|
+
bold: str = "\033[1m"
|
|
52
|
+
dim: str = "\033[2m"
|
|
53
|
+
italic: str = "\033[3m"
|
|
54
|
+
underline: str = "\033[4m"
|
|
55
|
+
reverse: str = "\033[7m"
|
|
56
|
+
|
|
57
|
+
red: str = "\033[31m"
|
|
58
|
+
green: str = "\033[32m"
|
|
59
|
+
yellow: str = "\033[33m"
|
|
60
|
+
blue: str = "\033[34m"
|
|
61
|
+
magenta: str = "\033[35m"
|
|
62
|
+
cyan: str = "\033[36m"
|
|
63
|
+
white: str = "\033[37m"
|
|
64
|
+
grey: str = "\033[90m"
|
|
65
|
+
|
|
66
|
+
bright_magenta: str = "\033[95m"
|
|
67
|
+
bright_cyan: str = "\033[96m"
|
|
68
|
+
bright_green: str = "\033[92m"
|
|
69
|
+
bright_yellow: str = "\033[93m"
|
|
70
|
+
bright_red: str = "\033[91m"
|
|
71
|
+
|
|
72
|
+
on_magenta: str = "\033[45m"
|
|
73
|
+
on_grey: str = "\033[100m"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
PLAIN = Palette(**{field: "" for field in Palette.__dataclass_fields__})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def palette() -> Palette:
|
|
80
|
+
"""Colors when the terminal wants them, empty strings when it does not."""
|
|
81
|
+
return Palette() if color_enabled() else PLAIN
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_ANSI = re.compile(r"\033\[[0-9;]*[A-Za-z]|\033\][^\a]*(?:\a|\033\\)")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def strip_ansi(text: str) -> str:
|
|
88
|
+
return _ANSI.sub("", text)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Width
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
_VARIATION_SELECTOR_16 = "️"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def display_width(text: str) -> int:
|
|
99
|
+
"""Columns ``text`` occupies, ignoring escape sequences.
|
|
100
|
+
|
|
101
|
+
``len()`` is wrong for the two things that show up in this UI: colour
|
|
102
|
+
escapes (zero width) and emoji (two columns). Getting it wrong misaligns
|
|
103
|
+
every row after it, so the menu measures with this instead.
|
|
104
|
+
"""
|
|
105
|
+
chars = list(strip_ansi(text))
|
|
106
|
+
width = 0
|
|
107
|
+
|
|
108
|
+
for index, char in enumerate(chars):
|
|
109
|
+
if char == _VARIATION_SELECTOR_16 or unicodedata.combining(char):
|
|
110
|
+
# U+FE0F only asks for the emoji presentation of the character
|
|
111
|
+
# before it; it takes no columns of its own. Counting it as one
|
|
112
|
+
# was worth two columns of drift per row on 🗑️ and friends.
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
if unicodedata.east_asian_width(char) in ("W", "F"):
|
|
116
|
+
width += 2
|
|
117
|
+
elif ord(char) >= 0x1F300:
|
|
118
|
+
# Emoji that Unicode still classifies as neutral (🗑, 🎞) but every
|
|
119
|
+
# terminal draws double width.
|
|
120
|
+
width += 2
|
|
121
|
+
elif index + 1 < len(chars) and chars[index + 1] == _VARIATION_SELECTOR_16:
|
|
122
|
+
# A narrow symbol promoted to emoji presentation (⌨️, ⬆️).
|
|
123
|
+
width += 2
|
|
124
|
+
else:
|
|
125
|
+
width += 1
|
|
126
|
+
|
|
127
|
+
return width
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def truncate(text: str, width: int, *, ellipsis: str = "…") -> str:
|
|
131
|
+
"""Cut ``text`` to ``width`` columns, appending an ellipsis when it had to."""
|
|
132
|
+
if width <= 0:
|
|
133
|
+
return ""
|
|
134
|
+
if display_width(text) <= width:
|
|
135
|
+
return text
|
|
136
|
+
|
|
137
|
+
budget = width - display_width(ellipsis)
|
|
138
|
+
out: list[str] = []
|
|
139
|
+
used = 0
|
|
140
|
+
for char in strip_ansi(text):
|
|
141
|
+
step = display_width(char)
|
|
142
|
+
if used + step > budget:
|
|
143
|
+
break
|
|
144
|
+
out.append(char)
|
|
145
|
+
used += step
|
|
146
|
+
return "".join(out) + ellipsis
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def fit(text: str, width: int) -> str:
|
|
150
|
+
"""Truncate to ``width`` columns, then pad with spaces to exactly that."""
|
|
151
|
+
cut = truncate(text, width)
|
|
152
|
+
return cut + " " * max(0, width - display_width(cut))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def hyperlink(label: str, url: str) -> str:
|
|
156
|
+
"""An OSC 8 terminal hyperlink, or the bare label when colour is off."""
|
|
157
|
+
if not color_enabled():
|
|
158
|
+
return label
|
|
159
|
+
return f"\033]8;;{url}\033\\{label}\033]8;;\033\\"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Terminal control
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
ALT_SCREEN_ON = "\033[?1049h"
|
|
167
|
+
ALT_SCREEN_OFF = "\033[?1049l"
|
|
168
|
+
CURSOR_HIDE = "\033[?25l"
|
|
169
|
+
CURSOR_SHOW = "\033[?25h"
|
|
170
|
+
CLEAR = "\033[H\033[2J"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def size() -> tuple[int, int]:
|
|
174
|
+
"""Terminal size as ``(columns, rows)``, with sane fallbacks."""
|
|
175
|
+
try:
|
|
176
|
+
columns, rows = os.get_terminal_size(sys.__stdout__.fileno())
|
|
177
|
+
except (OSError, ValueError, AttributeError):
|
|
178
|
+
columns, rows = 80, 24
|
|
179
|
+
return max(40, columns), max(10, rows)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def is_interactive() -> bool:
|
|
183
|
+
"""Whether a full-screen UI is possible at all."""
|
|
184
|
+
try:
|
|
185
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
186
|
+
except (ValueError, AttributeError):
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Term:
|
|
191
|
+
"""Alternate screen + raw input, restored no matter how the block exits.
|
|
192
|
+
|
|
193
|
+
A TUI that leaves the terminal in raw mode with the cursor hidden is worse
|
|
194
|
+
than no TUI at all, so teardown runs from ``__exit__`` *and* from handlers
|
|
195
|
+
for SIGINT/SIGTERM/SIGHUP, and is written to be safe to call twice.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, *, alt_screen: bool = True) -> None:
|
|
199
|
+
self.alt_screen = alt_screen
|
|
200
|
+
self.fd = -1
|
|
201
|
+
self._saved: list | None = None
|
|
202
|
+
self._active = False
|
|
203
|
+
self._previous_handlers: dict[int, object] = {}
|
|
204
|
+
self.resized = False
|
|
205
|
+
|
|
206
|
+
# -- lifecycle ------------------------------------------------------
|
|
207
|
+
def __enter__(self) -> Term:
|
|
208
|
+
self.fd = sys.stdin.fileno()
|
|
209
|
+
with suppress(termios.error, ValueError):
|
|
210
|
+
self._saved = termios.tcgetattr(self.fd)
|
|
211
|
+
tty.setraw(self.fd)
|
|
212
|
+
|
|
213
|
+
if self.alt_screen:
|
|
214
|
+
self._write(ALT_SCREEN_ON + CURSOR_HIDE)
|
|
215
|
+
|
|
216
|
+
self._active = True
|
|
217
|
+
self._install_handlers()
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
def __exit__(
|
|
221
|
+
self,
|
|
222
|
+
exc_type: type[BaseException] | None,
|
|
223
|
+
exc: BaseException | None,
|
|
224
|
+
traceback: TracebackType | None,
|
|
225
|
+
) -> None:
|
|
226
|
+
self.close()
|
|
227
|
+
|
|
228
|
+
def close(self) -> None:
|
|
229
|
+
if not self._active:
|
|
230
|
+
return
|
|
231
|
+
self._active = False
|
|
232
|
+
|
|
233
|
+
if self.alt_screen:
|
|
234
|
+
self._write(CURSOR_SHOW + ALT_SCREEN_OFF)
|
|
235
|
+
if self._saved is not None:
|
|
236
|
+
with suppress(termios.error, ValueError):
|
|
237
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, self._saved)
|
|
238
|
+
|
|
239
|
+
self._restore_handlers()
|
|
240
|
+
|
|
241
|
+
# -- suspending, for running a child command in the normal screen ----
|
|
242
|
+
def suspend(self) -> None:
|
|
243
|
+
"""Drop back to the normal screen and cooked input."""
|
|
244
|
+
if self.alt_screen:
|
|
245
|
+
self._write(CURSOR_SHOW + ALT_SCREEN_OFF)
|
|
246
|
+
if self._saved is not None:
|
|
247
|
+
with suppress(termios.error, ValueError):
|
|
248
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, self._saved)
|
|
249
|
+
|
|
250
|
+
def resume(self) -> None:
|
|
251
|
+
"""Return to the full-screen UI after :meth:`suspend`."""
|
|
252
|
+
with suppress(termios.error, ValueError):
|
|
253
|
+
tty.setraw(self.fd)
|
|
254
|
+
if self.alt_screen:
|
|
255
|
+
self._write(ALT_SCREEN_ON + CURSOR_HIDE)
|
|
256
|
+
|
|
257
|
+
# -- signals --------------------------------------------------------
|
|
258
|
+
def _install_handlers(self) -> None:
|
|
259
|
+
def on_resize(_signum: int, _frame: object) -> None:
|
|
260
|
+
self.resized = True
|
|
261
|
+
|
|
262
|
+
def on_kill(signum: int, _frame: object) -> None:
|
|
263
|
+
self.close()
|
|
264
|
+
# Re-raise with the default disposition so the exit status is the
|
|
265
|
+
# 128+N a shell expects, rather than a plain 0.
|
|
266
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
267
|
+
os.kill(os.getpid(), signum)
|
|
268
|
+
|
|
269
|
+
for signum, handler in ((signal.SIGWINCH, on_resize),):
|
|
270
|
+
with suppress(ValueError, OSError):
|
|
271
|
+
self._previous_handlers[signum] = signal.signal(signum, handler)
|
|
272
|
+
|
|
273
|
+
for signum in (signal.SIGTERM, signal.SIGHUP):
|
|
274
|
+
with suppress(ValueError, OSError):
|
|
275
|
+
self._previous_handlers[signum] = signal.signal(signum, on_kill)
|
|
276
|
+
|
|
277
|
+
def _restore_handlers(self) -> None:
|
|
278
|
+
for signum, handler in self._previous_handlers.items():
|
|
279
|
+
with suppress(ValueError, OSError, TypeError):
|
|
280
|
+
signal.signal(signum, handler) # type: ignore[arg-type]
|
|
281
|
+
self._previous_handlers.clear()
|
|
282
|
+
|
|
283
|
+
# -- output ---------------------------------------------------------
|
|
284
|
+
@staticmethod
|
|
285
|
+
def _write(text: str) -> None:
|
|
286
|
+
with suppress(OSError, ValueError):
|
|
287
|
+
sys.stdout.write(text)
|
|
288
|
+
sys.stdout.flush()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
# Input
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
_CSI_KEYS = {
|
|
296
|
+
"A": "up",
|
|
297
|
+
"B": "down",
|
|
298
|
+
"C": "right",
|
|
299
|
+
"D": "left",
|
|
300
|
+
"H": "home",
|
|
301
|
+
"F": "end",
|
|
302
|
+
"Z": "shift-tab",
|
|
303
|
+
}
|
|
304
|
+
_CSI_TILDE_KEYS = {
|
|
305
|
+
"1": "home",
|
|
306
|
+
"3": "delete",
|
|
307
|
+
"4": "end",
|
|
308
|
+
"5": "pgup",
|
|
309
|
+
"6": "pgdn",
|
|
310
|
+
"7": "home",
|
|
311
|
+
"8": "end",
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def read_key(timeout: float | None = None) -> str | None:
|
|
316
|
+
"""Read one keypress in raw mode and return a name for it.
|
|
317
|
+
|
|
318
|
+
Returns ``None`` on timeout (used so a SIGWINCH can redraw a frame that is
|
|
319
|
+
otherwise blocked in ``read``). Unknown escape sequences are swallowed
|
|
320
|
+
rather than leaking their bytes into a filter box.
|
|
321
|
+
"""
|
|
322
|
+
fd = sys.stdin.fileno()
|
|
323
|
+
|
|
324
|
+
if timeout is not None:
|
|
325
|
+
ready, _, _ = select.select([fd], [], [], timeout)
|
|
326
|
+
if not ready:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
first = os.read(fd, 1).decode("utf-8", "replace")
|
|
331
|
+
except (OSError, InterruptedError):
|
|
332
|
+
return None
|
|
333
|
+
if not first:
|
|
334
|
+
return "eof"
|
|
335
|
+
|
|
336
|
+
if first == "\x03":
|
|
337
|
+
return "ctrl-c"
|
|
338
|
+
if first == "\x04":
|
|
339
|
+
return "eof"
|
|
340
|
+
if first in ("\r", "\n"):
|
|
341
|
+
return "enter"
|
|
342
|
+
if first == "\t":
|
|
343
|
+
return "tab"
|
|
344
|
+
if first in ("\x7f", "\b"):
|
|
345
|
+
return "backspace"
|
|
346
|
+
if first == "\x15":
|
|
347
|
+
return "ctrl-u"
|
|
348
|
+
if first == "\x17":
|
|
349
|
+
return "ctrl-w"
|
|
350
|
+
if first != "\033":
|
|
351
|
+
# A multi-byte UTF-8 character arrives one byte at a time.
|
|
352
|
+
if ord(first[0]) >= 0x80:
|
|
353
|
+
extra = b""
|
|
354
|
+
for _ in range(3):
|
|
355
|
+
ready, _, _ = select.select([fd], [], [], 0.01)
|
|
356
|
+
if not ready:
|
|
357
|
+
break
|
|
358
|
+
extra += os.read(fd, 1)
|
|
359
|
+
return (first.encode("utf-8", "surrogateescape") + extra).decode("utf-8", "replace")
|
|
360
|
+
return first
|
|
361
|
+
|
|
362
|
+
# Escape, possibly introducing a sequence. A lone Escape has nothing
|
|
363
|
+
# following it, which is what the short select() below distinguishes.
|
|
364
|
+
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
365
|
+
if not ready:
|
|
366
|
+
return "esc"
|
|
367
|
+
|
|
368
|
+
second = os.read(fd, 1).decode("utf-8", "replace")
|
|
369
|
+
if second not in ("[", "O"):
|
|
370
|
+
return "esc"
|
|
371
|
+
|
|
372
|
+
params = ""
|
|
373
|
+
while True:
|
|
374
|
+
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
375
|
+
if not ready:
|
|
376
|
+
return "esc"
|
|
377
|
+
char = os.read(fd, 1).decode("utf-8", "replace")
|
|
378
|
+
if char.isdigit() or char == ";":
|
|
379
|
+
params += char
|
|
380
|
+
continue
|
|
381
|
+
if char == "~":
|
|
382
|
+
return _CSI_TILDE_KEYS.get(params, "unknown")
|
|
383
|
+
return _CSI_KEYS.get(char, "unknown")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# Frame buffer
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class Screen:
|
|
392
|
+
"""Compose a frame in memory, then paint it in one write."""
|
|
393
|
+
|
|
394
|
+
def __init__(self) -> None:
|
|
395
|
+
self.columns, self.rows = size()
|
|
396
|
+
self._lines: list[str] = []
|
|
397
|
+
|
|
398
|
+
def start(self) -> None:
|
|
399
|
+
self.columns, self.rows = size()
|
|
400
|
+
self._lines = []
|
|
401
|
+
|
|
402
|
+
def line(self, text: str = "") -> None:
|
|
403
|
+
self._lines.append(truncate(text, self.columns))
|
|
404
|
+
|
|
405
|
+
def lines(self, texts: Iterable[str]) -> None:
|
|
406
|
+
for text in texts:
|
|
407
|
+
self.line(text)
|
|
408
|
+
|
|
409
|
+
@property
|
|
410
|
+
def used(self) -> int:
|
|
411
|
+
return len(self._lines)
|
|
412
|
+
|
|
413
|
+
def pad_to(self, row: int) -> None:
|
|
414
|
+
while len(self._lines) < row:
|
|
415
|
+
self._lines.append("")
|
|
416
|
+
|
|
417
|
+
def flush(self) -> None:
|
|
418
|
+
# \033[K after each line clears whatever the previous frame left there,
|
|
419
|
+
# which is cheaper and steadier than clearing the whole screen first.
|
|
420
|
+
body = "\033[K\r\n".join(self._lines[: self.rows])
|
|
421
|
+
with suppress(OSError, ValueError):
|
|
422
|
+
sys.stdout.write("\033[H" + body + "\033[K\033[J")
|
|
423
|
+
sys.stdout.flush()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
# Line prompts (used outside the alternate screen)
|
|
428
|
+
# ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _setup_readline(history_file: Path | None = None) -> object | None:
|
|
432
|
+
"""Enable editing, filename completion and history for ``input()``.
|
|
433
|
+
|
|
434
|
+
macOS ships readline as libedit, which needs a different bind syntax; both
|
|
435
|
+
are handled. A missing readline module is not fatal - the prompt just loses
|
|
436
|
+
arrow-key editing.
|
|
437
|
+
"""
|
|
438
|
+
try:
|
|
439
|
+
import readline
|
|
440
|
+
except ImportError:
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
if "libedit" in (getattr(readline, "__doc__", "") or ""):
|
|
444
|
+
readline.parse_and_bind("bind ^I rl_complete")
|
|
445
|
+
else:
|
|
446
|
+
readline.parse_and_bind("tab: complete")
|
|
447
|
+
|
|
448
|
+
readline.set_completer_delims(" \t\n=")
|
|
449
|
+
|
|
450
|
+
if history_file is not None:
|
|
451
|
+
with suppress(OSError):
|
|
452
|
+
history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
453
|
+
if history_file.exists():
|
|
454
|
+
readline.read_history_file(history_file)
|
|
455
|
+
readline.set_history_length(200)
|
|
456
|
+
|
|
457
|
+
return readline
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def ask(
|
|
461
|
+
label: str,
|
|
462
|
+
*,
|
|
463
|
+
default: str = "",
|
|
464
|
+
kind: str = "text",
|
|
465
|
+
choices: Sequence[str] = (),
|
|
466
|
+
history_file: Path | None = None,
|
|
467
|
+
) -> str:
|
|
468
|
+
"""Ask one question and return the answer, or the default on empty input.
|
|
469
|
+
|
|
470
|
+
``kind="path"`` turns on filename completion; ``kind="choice"`` shows the
|
|
471
|
+
valid values and re-asks until one of them is given.
|
|
472
|
+
"""
|
|
473
|
+
colors = palette()
|
|
474
|
+
readline = _setup_readline(history_file)
|
|
475
|
+
|
|
476
|
+
if readline is not None:
|
|
477
|
+
if kind == "path":
|
|
478
|
+
readline.set_completer(_path_completer)
|
|
479
|
+
elif choices:
|
|
480
|
+
readline.set_completer(_choice_completer(choices))
|
|
481
|
+
else:
|
|
482
|
+
readline.set_completer(lambda *_: None)
|
|
483
|
+
|
|
484
|
+
suffix = ""
|
|
485
|
+
if choices:
|
|
486
|
+
suffix = f" {colors.grey}[{'/'.join(choices)}]{colors.reset}"
|
|
487
|
+
elif default:
|
|
488
|
+
suffix = f" {colors.grey}[{default}]{colors.reset}"
|
|
489
|
+
|
|
490
|
+
prompt = f"{colors.cyan}?{colors.reset} {colors.bold}{label}{colors.reset}{suffix}: "
|
|
491
|
+
|
|
492
|
+
while True:
|
|
493
|
+
try:
|
|
494
|
+
answer = input(prompt).strip()
|
|
495
|
+
except EOFError:
|
|
496
|
+
print()
|
|
497
|
+
return default
|
|
498
|
+
|
|
499
|
+
if not answer:
|
|
500
|
+
answer = default
|
|
501
|
+
if kind == "path" and answer:
|
|
502
|
+
answer = str(Path(os.path.expandvars(answer)).expanduser())
|
|
503
|
+
if choices and answer not in choices:
|
|
504
|
+
print(f"{colors.yellow} Pick one of: {', '.join(choices)}{colors.reset}")
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
if readline is not None and history_file is not None and answer:
|
|
508
|
+
with suppress(OSError):
|
|
509
|
+
readline.write_history_file(history_file)
|
|
510
|
+
return answer
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def confirm(question: str, *, default: bool = True) -> bool:
|
|
514
|
+
"""A yes/no prompt. Anything unparseable re-asks rather than guessing."""
|
|
515
|
+
colors = palette()
|
|
516
|
+
hint = "Y/n" if default else "y/N"
|
|
517
|
+
while True:
|
|
518
|
+
try:
|
|
519
|
+
answer = (
|
|
520
|
+
input(
|
|
521
|
+
f"{colors.cyan}?{colors.reset} {colors.bold}{question}{colors.reset} "
|
|
522
|
+
f"{colors.grey}[{hint}]{colors.reset} "
|
|
523
|
+
)
|
|
524
|
+
.strip()
|
|
525
|
+
.lower()
|
|
526
|
+
)
|
|
527
|
+
except EOFError:
|
|
528
|
+
print()
|
|
529
|
+
return default
|
|
530
|
+
if not answer:
|
|
531
|
+
return default
|
|
532
|
+
if answer in ("y", "yes"):
|
|
533
|
+
return True
|
|
534
|
+
if answer in ("n", "no"):
|
|
535
|
+
return False
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def choose(
|
|
539
|
+
title: str,
|
|
540
|
+
options: Sequence[tuple[str, str]],
|
|
541
|
+
*,
|
|
542
|
+
selected: Iterable[int] = (),
|
|
543
|
+
multi: bool = True,
|
|
544
|
+
) -> list[int]:
|
|
545
|
+
"""A checkbox / radio list, driven with the arrow keys.
|
|
546
|
+
|
|
547
|
+
Returns the indices the user accepted. Falls back to a numbered text
|
|
548
|
+
prompt when there is no tty, so the wizard still works over a pipe.
|
|
549
|
+
"""
|
|
550
|
+
chosen = set(selected)
|
|
551
|
+
|
|
552
|
+
if not is_interactive():
|
|
553
|
+
return sorted(chosen)
|
|
554
|
+
|
|
555
|
+
colors = palette()
|
|
556
|
+
cursor = min(chosen, default=0)
|
|
557
|
+
|
|
558
|
+
with Term() as term:
|
|
559
|
+
screen = Screen()
|
|
560
|
+
while True:
|
|
561
|
+
screen.start()
|
|
562
|
+
screen.line()
|
|
563
|
+
screen.line(f" {colors.bold}{title}{colors.reset}")
|
|
564
|
+
screen.line()
|
|
565
|
+
for index, (label, hint) in enumerate(options):
|
|
566
|
+
mark = "◉" if index in chosen else "○"
|
|
567
|
+
if not multi:
|
|
568
|
+
mark = "●" if index in chosen else "○"
|
|
569
|
+
pointer = f"{colors.cyan}▸{colors.reset}" if index == cursor else " "
|
|
570
|
+
body = f"{mark} {label}"
|
|
571
|
+
if index == cursor:
|
|
572
|
+
body = f"{colors.bold}{body}{colors.reset}"
|
|
573
|
+
detail = f" {colors.grey}{hint}{colors.reset}" if hint else ""
|
|
574
|
+
screen.line(f" {pointer} {body}{detail}")
|
|
575
|
+
screen.line()
|
|
576
|
+
screen.line(
|
|
577
|
+
f" {colors.grey}↑↓ move space toggle enter accept q cancel{colors.reset}"
|
|
578
|
+
)
|
|
579
|
+
screen.flush()
|
|
580
|
+
|
|
581
|
+
key = read_key(timeout=0.5)
|
|
582
|
+
if key is None:
|
|
583
|
+
continue
|
|
584
|
+
if key in ("up", "k"):
|
|
585
|
+
cursor = (cursor - 1) % len(options)
|
|
586
|
+
elif key in ("down", "j"):
|
|
587
|
+
cursor = (cursor + 1) % len(options)
|
|
588
|
+
elif key == " ":
|
|
589
|
+
if multi:
|
|
590
|
+
chosen.symmetric_difference_update({cursor})
|
|
591
|
+
else:
|
|
592
|
+
chosen = {cursor}
|
|
593
|
+
elif key == "a" and multi:
|
|
594
|
+
chosen = set(range(len(options))) if len(chosen) < len(options) else set()
|
|
595
|
+
elif key == "enter":
|
|
596
|
+
if not chosen and not multi:
|
|
597
|
+
chosen = {cursor}
|
|
598
|
+
term.close()
|
|
599
|
+
return sorted(chosen)
|
|
600
|
+
elif key in ("q", "esc", "ctrl-c"):
|
|
601
|
+
term.close()
|
|
602
|
+
return []
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _path_completer(text: str, state: int) -> str | None:
|
|
606
|
+
"""readline completer offering filesystem paths, with a / on directories."""
|
|
607
|
+
expanded = Path(os.path.expandvars(text)).expanduser()
|
|
608
|
+
typed_directory = (
|
|
609
|
+
str(expanded.parent) if str(expanded.parent) != "." or text.startswith(".") else ""
|
|
610
|
+
)
|
|
611
|
+
directory = expanded.parent if str(expanded.parent) else Path()
|
|
612
|
+
stem = expanded.name if not text.endswith("/") else ""
|
|
613
|
+
if text.endswith("/"):
|
|
614
|
+
directory = expanded
|
|
615
|
+
|
|
616
|
+
try:
|
|
617
|
+
entries = sorted(entry.name for entry in directory.iterdir())
|
|
618
|
+
except OSError:
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
matches = []
|
|
622
|
+
for entry in entries:
|
|
623
|
+
if not entry.startswith(stem):
|
|
624
|
+
continue
|
|
625
|
+
full = str(directory / entry) if typed_directory or text.endswith("/") else entry
|
|
626
|
+
matches.append(f"{full}/" if (directory / entry).is_dir() else full)
|
|
627
|
+
|
|
628
|
+
return matches[state] if state < len(matches) else None
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _choice_completer(choices: Sequence[str]):
|
|
632
|
+
def complete(text: str, state: int) -> str | None:
|
|
633
|
+
matches = [choice for choice in choices if choice.startswith(text)]
|
|
634
|
+
return matches[state] if state < len(matches) else None
|
|
635
|
+
|
|
636
|
+
return complete
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def pause(message: str = "Press any key to continue") -> None:
|
|
640
|
+
"""Wait for a keypress, in raw mode so any key works (not just Enter)."""
|
|
641
|
+
colors = palette()
|
|
642
|
+
sys.stdout.write(f"\n{colors.grey}{message}…{colors.reset}")
|
|
643
|
+
sys.stdout.flush()
|
|
644
|
+
if is_interactive():
|
|
645
|
+
with Term(alt_screen=False):
|
|
646
|
+
read_key()
|
|
647
|
+
sys.stdout.write("\n")
|
|
648
|
+
sys.stdout.flush()
|