@codebend3r/che 1.1.0 → 1.2.1
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/README.md +58 -26
- package/bin/art.py +22 -20
- package/bin/branchsync.py +324 -0
- package/bin/che.py +208 -587
- package/bin/commands.py +219 -231
- package/bin/doctor.py +220 -0
- package/bin/drives/eject-all-drives.py +21 -38
- package/bin/drives/mount-all-drives.py +18 -62
- package/bin/drives/ping-nas.py +77 -105
- package/bin/files/compress-folders.py +7 -11
- package/bin/files/delete-by-ext.py +10 -14
- package/bin/files/delete-empty-folders.py +33 -37
- package/bin/files/delete-smb-files.py +12 -21
- package/bin/files/files-under-size.py +5 -11
- package/bin/files/find-largest-files.py +21 -25
- package/bin/files/list-permission.py +40 -0
- package/bin/files/make-alpha-dir.py +2 -0
- package/bin/git/all-actions.py +350 -253
- package/bin/git/checkout-my-branches.py +42 -63
- package/bin/git/clean-stale-branches.py +23 -48
- package/bin/git/prune-worktrees.py +12 -51
- package/bin/git/sync-all-branches.py +75 -297
- package/bin/git/update-local-branches.py +67 -73
- package/bin/gitutils.py +263 -0
- package/bin/install.py +393 -1182
- package/bin/machine.py +345 -0
- package/bin/media.py +127 -0
- package/bin/menu.py +437 -0
- package/bin/nas.py +102 -0
- package/bin/rcfile.py +380 -0
- package/bin/shellgen.py +503 -431
- package/bin/system/btop-launch.py +17 -13
- package/bin/system/update-brew.py +57 -68
- package/bin/tui.py +156 -153
- package/bin/utils.py +165 -237
- package/bin/video/delete-duplicate-videos.py +64 -62
- package/bin/video/detect-green-magenta-videos.py +105 -95
- package/bin/video/find-movie-by-year.py +3 -0
- package/bin/video/find-video-mkv-issues.py +48 -65
- package/bin/video/fix-codecs.py +25 -30
- package/bin/video/largest-tv-shows.py +60 -57
- package/bin/video/remove-metadata.py +3 -6
- package/bin/video/rename-video-file.py +74 -84
- package/bin/video/scan-videos-audio-language.py +16 -33
- package/bin/video/show-codecs.py +20 -24
- package/bin/video/validate-video-files.py +5 -8
- package/bin/video/video-list.py +17 -20
- package/package.json +4 -10
- package/shell/che.bash +33 -33
- package/shell/che.fish +7 -7
- package/shell/che.zsh +33 -33
- package/shell/completions/_che +19 -19
- package/shell/completions/che.bash +15 -15
- package/shell/completions/che.fish +34 -50
package/bin/tui.py
CHANGED
|
@@ -22,6 +22,7 @@ Library module: no shebang, not executable.
|
|
|
22
22
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
|
+
import functools
|
|
25
26
|
import os
|
|
26
27
|
import re
|
|
27
28
|
import select
|
|
@@ -30,11 +31,11 @@ import sys
|
|
|
30
31
|
import termios
|
|
31
32
|
import tty
|
|
32
33
|
import unicodedata
|
|
33
|
-
from collections.abc import Iterable, Sequence
|
|
34
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
34
35
|
from contextlib import suppress
|
|
35
36
|
from dataclasses import dataclass
|
|
36
37
|
from pathlib import Path
|
|
37
|
-
from types import TracebackType
|
|
38
|
+
from types import ModuleType, TracebackType
|
|
38
39
|
|
|
39
40
|
from utils import color_enabled
|
|
40
41
|
|
|
@@ -49,28 +50,14 @@ class Palette:
|
|
|
49
50
|
|
|
50
51
|
reset: str = "\033[0m"
|
|
51
52
|
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
53
|
|
|
57
54
|
red: str = "\033[31m"
|
|
58
55
|
green: str = "\033[32m"
|
|
59
56
|
yellow: str = "\033[33m"
|
|
60
57
|
blue: str = "\033[34m"
|
|
61
|
-
magenta: str = "\033[35m"
|
|
62
58
|
cyan: str = "\033[36m"
|
|
63
|
-
white: str = "\033[37m"
|
|
64
59
|
grey: str = "\033[90m"
|
|
65
|
-
|
|
66
60
|
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
61
|
|
|
75
62
|
|
|
76
63
|
PLAIN = Palette(**{field: "" for field in Palette.__dataclass_fields__})
|
|
@@ -92,39 +79,60 @@ def strip_ansi(text: str) -> str:
|
|
|
92
79
|
# Width
|
|
93
80
|
# ---------------------------------------------------------------------------
|
|
94
81
|
|
|
95
|
-
_VARIATION_SELECTOR_16 = "
|
|
82
|
+
_VARIATION_SELECTOR_16 = "\ufe0f"
|
|
96
83
|
|
|
97
84
|
|
|
98
|
-
def
|
|
99
|
-
"""
|
|
85
|
+
def _is_modifier(char: str) -> bool:
|
|
86
|
+
"""Takes no columns of its own: U+FE0F or a combining mark.
|
|
100
87
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
every row after it, so the menu measures with this instead.
|
|
88
|
+
U+FE0F only asks for the emoji presentation of the character before it.
|
|
89
|
+
Counting it as one column was worth two columns of drift per row on 🗑️.
|
|
104
90
|
"""
|
|
105
|
-
|
|
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
|
|
91
|
+
return char == _VARIATION_SELECTOR_16 or bool(unicodedata.combining(char))
|
|
114
92
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
93
|
+
|
|
94
|
+
def _clusters(text: str) -> Iterator[tuple[str, int]]:
|
|
95
|
+
"""``text`` without escapes, as (character plus its modifiers, columns) pairs.
|
|
96
|
+
|
|
97
|
+
``display_width`` and ``truncate`` both measure through this, so a cut can
|
|
98
|
+
never land between an emoji and the selector that makes it two columns.
|
|
99
|
+
"""
|
|
100
|
+
chars = strip_ansi(text)
|
|
101
|
+
index = 0
|
|
102
|
+
while index < len(chars):
|
|
103
|
+
end = index + 1
|
|
104
|
+
while end < len(chars) and _is_modifier(chars[end]):
|
|
105
|
+
end += 1
|
|
106
|
+
cluster = chars[index:end]
|
|
107
|
+
base = cluster[0]
|
|
108
|
+
|
|
109
|
+
if _is_modifier(base):
|
|
110
|
+
# A modifier with nothing before it to modify.
|
|
111
|
+
width = 0
|
|
112
|
+
elif unicodedata.east_asian_width(base) in ("W", "F"):
|
|
113
|
+
width = 2
|
|
114
|
+
elif ord(base) >= 0x1F300:
|
|
118
115
|
# Emoji that Unicode still classifies as neutral (🗑, 🎞) but every
|
|
119
116
|
# terminal draws double width.
|
|
120
|
-
width
|
|
121
|
-
elif
|
|
117
|
+
width = 2
|
|
118
|
+
elif cluster[1:2] == _VARIATION_SELECTOR_16:
|
|
122
119
|
# A narrow symbol promoted to emoji presentation (⌨️, ⬆️).
|
|
123
|
-
width
|
|
120
|
+
width = 2
|
|
124
121
|
else:
|
|
125
|
-
width
|
|
122
|
+
width = 1
|
|
123
|
+
|
|
124
|
+
yield cluster, width
|
|
125
|
+
index = end
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def display_width(text: str) -> int:
|
|
129
|
+
"""Columns ``text`` occupies, ignoring escape sequences.
|
|
126
130
|
|
|
127
|
-
|
|
131
|
+
``len()`` is wrong for the two things that show up in this UI: colour
|
|
132
|
+
escapes (zero width) and emoji (two columns). Getting it wrong misaligns
|
|
133
|
+
every row after it, so the menu measures with this instead.
|
|
134
|
+
"""
|
|
135
|
+
return sum(width for _, width in _clusters(text))
|
|
128
136
|
|
|
129
137
|
|
|
130
138
|
def truncate(text: str, width: int, *, ellipsis: str = "…") -> str:
|
|
@@ -137,11 +145,10 @@ def truncate(text: str, width: int, *, ellipsis: str = "…") -> str:
|
|
|
137
145
|
budget = width - display_width(ellipsis)
|
|
138
146
|
out: list[str] = []
|
|
139
147
|
used = 0
|
|
140
|
-
for
|
|
141
|
-
step = display_width(char)
|
|
148
|
+
for cluster, step in _clusters(text):
|
|
142
149
|
if used + step > budget:
|
|
143
150
|
break
|
|
144
|
-
out.append(
|
|
151
|
+
out.append(cluster)
|
|
145
152
|
used += step
|
|
146
153
|
return "".join(out) + ellipsis
|
|
147
154
|
|
|
@@ -167,7 +174,6 @@ ALT_SCREEN_ON = "\033[?1049h"
|
|
|
167
174
|
ALT_SCREEN_OFF = "\033[?1049l"
|
|
168
175
|
CURSOR_HIDE = "\033[?25l"
|
|
169
176
|
CURSOR_SHOW = "\033[?25h"
|
|
170
|
-
CLEAR = "\033[H\033[2J"
|
|
171
177
|
|
|
172
178
|
|
|
173
179
|
def size() -> tuple[int, int]:
|
|
@@ -191,8 +197,9 @@ class Term:
|
|
|
191
197
|
"""Alternate screen + raw input, restored no matter how the block exits.
|
|
192
198
|
|
|
193
199
|
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__``
|
|
195
|
-
|
|
200
|
+
than no TUI at all, so teardown runs from ``__exit__`` (which an exception,
|
|
201
|
+
Ctrl-C's KeyboardInterrupt included, also reaches) *and* from handlers for
|
|
202
|
+
SIGTERM/SIGHUP, and is written to be safe to call twice.
|
|
196
203
|
"""
|
|
197
204
|
|
|
198
205
|
def __init__(self, *, alt_screen: bool = True) -> None:
|
|
@@ -201,17 +208,13 @@ class Term:
|
|
|
201
208
|
self._saved: list | None = None
|
|
202
209
|
self._active = False
|
|
203
210
|
self._previous_handlers: dict[int, object] = {}
|
|
204
|
-
self.resized = False
|
|
205
211
|
|
|
206
212
|
# -- lifecycle ------------------------------------------------------
|
|
207
213
|
def __enter__(self) -> Term:
|
|
208
214
|
self.fd = sys.stdin.fileno()
|
|
209
215
|
with suppress(termios.error, ValueError):
|
|
210
216
|
self._saved = termios.tcgetattr(self.fd)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if self.alt_screen:
|
|
214
|
-
self._write(ALT_SCREEN_ON + CURSOR_HIDE)
|
|
217
|
+
self.resume()
|
|
215
218
|
|
|
216
219
|
self._active = True
|
|
217
220
|
self._install_handlers()
|
|
@@ -229,13 +232,7 @@ class Term:
|
|
|
229
232
|
if not self._active:
|
|
230
233
|
return
|
|
231
234
|
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
|
-
|
|
235
|
+
self.suspend()
|
|
239
236
|
self._restore_handlers()
|
|
240
237
|
|
|
241
238
|
# -- suspending, for running a child command in the normal screen ----
|
|
@@ -256,9 +253,6 @@ class Term:
|
|
|
256
253
|
|
|
257
254
|
# -- signals --------------------------------------------------------
|
|
258
255
|
def _install_handlers(self) -> None:
|
|
259
|
-
def on_resize(_signum: int, _frame: object) -> None:
|
|
260
|
-
self.resized = True
|
|
261
|
-
|
|
262
256
|
def on_kill(signum: int, _frame: object) -> None:
|
|
263
257
|
self.close()
|
|
264
258
|
# Re-raise with the default disposition so the exit status is the
|
|
@@ -266,10 +260,6 @@ class Term:
|
|
|
266
260
|
signal.signal(signum, signal.SIG_DFL)
|
|
267
261
|
os.kill(os.getpid(), signum)
|
|
268
262
|
|
|
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
263
|
for signum in (signal.SIGTERM, signal.SIGHUP):
|
|
274
264
|
with suppress(ValueError, OSError):
|
|
275
265
|
self._previous_handlers[signum] = signal.signal(signum, on_kill)
|
|
@@ -292,6 +282,17 @@ class Term:
|
|
|
292
282
|
# Input
|
|
293
283
|
# ---------------------------------------------------------------------------
|
|
294
284
|
|
|
285
|
+
_CONTROL_KEYS = {
|
|
286
|
+
"\x03": "ctrl-c",
|
|
287
|
+
"\x04": "eof",
|
|
288
|
+
"\r": "enter",
|
|
289
|
+
"\n": "enter",
|
|
290
|
+
"\t": "tab",
|
|
291
|
+
"\x7f": "backspace",
|
|
292
|
+
"\b": "backspace",
|
|
293
|
+
"\x15": "ctrl-u",
|
|
294
|
+
"\x17": "ctrl-w",
|
|
295
|
+
}
|
|
295
296
|
_CSI_KEYS = {
|
|
296
297
|
"A": "up",
|
|
297
298
|
"B": "down",
|
|
@@ -312,12 +313,23 @@ _CSI_TILDE_KEYS = {
|
|
|
312
313
|
}
|
|
313
314
|
|
|
314
315
|
|
|
316
|
+
def _utf8_tail(lead: int) -> int:
|
|
317
|
+
"""How many continuation bytes follow ``lead`` in a UTF-8 sequence."""
|
|
318
|
+
if 0xC0 <= lead < 0xE0:
|
|
319
|
+
return 1
|
|
320
|
+
if 0xE0 <= lead < 0xF0:
|
|
321
|
+
return 2
|
|
322
|
+
if 0xF0 <= lead < 0xF8:
|
|
323
|
+
return 3
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
315
327
|
def read_key(timeout: float | None = None) -> str | None:
|
|
316
328
|
"""Read one keypress in raw mode and return a name for it.
|
|
317
329
|
|
|
318
|
-
Returns ``None`` on timeout
|
|
319
|
-
|
|
320
|
-
|
|
330
|
+
Returns ``None`` on timeout, which lets the menu redraw for a resize while
|
|
331
|
+
no key is pressed. Unknown escape sequences are swallowed rather than
|
|
332
|
+
leaking their bytes into a filter box.
|
|
321
333
|
"""
|
|
322
334
|
fd = sys.stdin.fileno()
|
|
323
335
|
|
|
@@ -327,36 +339,25 @@ def read_key(timeout: float | None = None) -> str | None:
|
|
|
327
339
|
return None
|
|
328
340
|
|
|
329
341
|
try:
|
|
330
|
-
|
|
331
|
-
except
|
|
342
|
+
raw = os.read(fd, 1)
|
|
343
|
+
except OSError:
|
|
332
344
|
return None
|
|
333
|
-
if not
|
|
345
|
+
if not raw:
|
|
334
346
|
return "eof"
|
|
335
347
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if first
|
|
347
|
-
return
|
|
348
|
-
if first == "\x17":
|
|
349
|
-
return "ctrl-w"
|
|
348
|
+
# A multi-byte UTF-8 character arrives one byte at a time. Its lead byte
|
|
349
|
+
# says exactly how many more belong to it, so the next keystroke is never
|
|
350
|
+
# read along with it.
|
|
351
|
+
for _ in range(_utf8_tail(raw[0])):
|
|
352
|
+
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
353
|
+
if not ready:
|
|
354
|
+
break
|
|
355
|
+
raw += os.read(fd, 1)
|
|
356
|
+
first = raw.decode("utf-8", "replace")
|
|
357
|
+
|
|
358
|
+
if first in _CONTROL_KEYS:
|
|
359
|
+
return _CONTROL_KEYS[first]
|
|
350
360
|
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
361
|
return first
|
|
361
362
|
|
|
362
363
|
# Escape, possibly introducing a sequence. A lone Escape has nothing
|
|
@@ -392,12 +393,11 @@ class Screen:
|
|
|
392
393
|
"""Compose a frame in memory, then paint it in one write."""
|
|
393
394
|
|
|
394
395
|
def __init__(self) -> None:
|
|
395
|
-
self.
|
|
396
|
-
self._lines: list[str] = []
|
|
396
|
+
self.start()
|
|
397
397
|
|
|
398
398
|
def start(self) -> None:
|
|
399
399
|
self.columns, self.rows = size()
|
|
400
|
-
self._lines = []
|
|
400
|
+
self._lines: list[str] = []
|
|
401
401
|
|
|
402
402
|
def line(self, text: str = "") -> None:
|
|
403
403
|
self._lines.append(truncate(text, self.columns))
|
|
@@ -428,8 +428,9 @@ class Screen:
|
|
|
428
428
|
# ---------------------------------------------------------------------------
|
|
429
429
|
|
|
430
430
|
|
|
431
|
-
|
|
432
|
-
|
|
431
|
+
@functools.cache
|
|
432
|
+
def _readline() -> ModuleType | None:
|
|
433
|
+
"""The readline module, set up once for editing and filename completion.
|
|
433
434
|
|
|
434
435
|
macOS ships readline as libedit, which needs a different bind syntax; both
|
|
435
436
|
are handled. A missing readline module is not fatal - the prompt just loses
|
|
@@ -440,21 +441,30 @@ def _setup_readline(history_file: Path | None = None) -> object | None:
|
|
|
440
441
|
except ImportError:
|
|
441
442
|
return None
|
|
442
443
|
|
|
443
|
-
if "libedit" in (
|
|
444
|
+
if "libedit" in (readline.__doc__ or ""):
|
|
444
445
|
readline.parse_and_bind("bind ^I rl_complete")
|
|
445
446
|
else:
|
|
446
447
|
readline.parse_and_bind("tab: complete")
|
|
447
448
|
|
|
448
449
|
readline.set_completer_delims(" \t\n=")
|
|
450
|
+
return readline
|
|
449
451
|
|
|
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
452
|
|
|
457
|
-
|
|
453
|
+
def _load_history(readline: ModuleType, history_file: Path | None) -> None:
|
|
454
|
+
"""Make ``history_file`` the only history the next ``input()`` offers.
|
|
455
|
+
|
|
456
|
+
``read_history_file`` appends to what is already in memory, so without the
|
|
457
|
+
clear every prompt would offer every earlier prompt's answers - and save
|
|
458
|
+
them all into its own file.
|
|
459
|
+
"""
|
|
460
|
+
readline.clear_history()
|
|
461
|
+
if history_file is None:
|
|
462
|
+
return
|
|
463
|
+
with suppress(OSError):
|
|
464
|
+
history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
465
|
+
if history_file.exists():
|
|
466
|
+
readline.read_history_file(history_file)
|
|
467
|
+
readline.set_history_length(200)
|
|
458
468
|
|
|
459
469
|
|
|
460
470
|
def ask(
|
|
@@ -467,17 +477,20 @@ def ask(
|
|
|
467
477
|
) -> str:
|
|
468
478
|
"""Ask one question and return the answer, or the default on empty input.
|
|
469
479
|
|
|
470
|
-
``kind="path"`` turns on filename completion; ``
|
|
471
|
-
|
|
480
|
+
``kind="path"`` turns on filename completion; ``choices`` shows the valid
|
|
481
|
+
values and re-asks until one of them is given.
|
|
472
482
|
"""
|
|
473
483
|
colors = palette()
|
|
474
|
-
readline =
|
|
484
|
+
readline = _readline()
|
|
475
485
|
|
|
476
486
|
if readline is not None:
|
|
487
|
+
_load_history(readline, history_file)
|
|
477
488
|
if kind == "path":
|
|
478
|
-
readline.set_completer(
|
|
489
|
+
readline.set_completer(_completer(_path_matches))
|
|
479
490
|
elif choices:
|
|
480
|
-
readline.set_completer(
|
|
491
|
+
readline.set_completer(
|
|
492
|
+
_completer(lambda text: [c for c in choices if c.startswith(text)])
|
|
493
|
+
)
|
|
481
494
|
else:
|
|
482
495
|
readline.set_completer(lambda *_: None)
|
|
483
496
|
|
|
@@ -540,12 +553,12 @@ def choose(
|
|
|
540
553
|
options: Sequence[tuple[str, str]],
|
|
541
554
|
*,
|
|
542
555
|
selected: Iterable[int] = (),
|
|
543
|
-
multi: bool = True,
|
|
544
556
|
) -> list[int]:
|
|
545
|
-
"""A checkbox
|
|
557
|
+
"""A checkbox list, driven with the arrow keys.
|
|
546
558
|
|
|
547
|
-
Returns the indices the user accepted.
|
|
548
|
-
|
|
559
|
+
Returns the indices the user accepted. With no tty there is nothing to
|
|
560
|
+
drive, so the preselection comes back unchanged and the wizard still works
|
|
561
|
+
over a pipe.
|
|
549
562
|
"""
|
|
550
563
|
chosen = set(selected)
|
|
551
564
|
|
|
@@ -555,7 +568,7 @@ def choose(
|
|
|
555
568
|
colors = palette()
|
|
556
569
|
cursor = min(chosen, default=0)
|
|
557
570
|
|
|
558
|
-
with Term()
|
|
571
|
+
with Term():
|
|
559
572
|
screen = Screen()
|
|
560
573
|
while True:
|
|
561
574
|
screen.start()
|
|
@@ -564,8 +577,6 @@ def choose(
|
|
|
564
577
|
screen.line()
|
|
565
578
|
for index, (label, hint) in enumerate(options):
|
|
566
579
|
mark = "◉" if index in chosen else "○"
|
|
567
|
-
if not multi:
|
|
568
|
-
mark = "●" if index in chosen else "○"
|
|
569
580
|
pointer = f"{colors.cyan}▸{colors.reset}" if index == cursor else " "
|
|
570
581
|
body = f"{mark} {label}"
|
|
571
582
|
if index == cursor:
|
|
@@ -586,56 +597,48 @@ def choose(
|
|
|
586
597
|
elif key in ("down", "j"):
|
|
587
598
|
cursor = (cursor + 1) % len(options)
|
|
588
599
|
elif key == " ":
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
else:
|
|
592
|
-
chosen = {cursor}
|
|
593
|
-
elif key == "a" and multi:
|
|
600
|
+
chosen.symmetric_difference_update({cursor})
|
|
601
|
+
elif key == "a":
|
|
594
602
|
chosen = set(range(len(options))) if len(chosen) < len(options) else set()
|
|
595
603
|
elif key == "enter":
|
|
596
|
-
if not chosen and not multi:
|
|
597
|
-
chosen = {cursor}
|
|
598
|
-
term.close()
|
|
599
604
|
return sorted(chosen)
|
|
600
605
|
elif key in ("q", "esc", "ctrl-c"):
|
|
601
|
-
term.close()
|
|
602
606
|
return []
|
|
603
607
|
|
|
604
608
|
|
|
605
|
-
def
|
|
606
|
-
"""readline completer
|
|
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
|
|
609
|
+
def _completer(match: Callable[[str], list[str]]) -> Callable[[str, int], str | None]:
|
|
610
|
+
"""A readline completer over ``match(text)``, worked out once per Tab.
|
|
629
611
|
|
|
612
|
+
readline asks for match 0, 1, 2, ... until it gets None. Listing the
|
|
613
|
+
matches afresh on every call made one Tab in a folder of a few thousand
|
|
614
|
+
entries on an SMB share quadratic in stats, which reads as a hang.
|
|
615
|
+
"""
|
|
616
|
+
matches: list[str] = []
|
|
630
617
|
|
|
631
|
-
def _choice_completer(choices: Sequence[str]):
|
|
632
618
|
def complete(text: str, state: int) -> str | None:
|
|
633
|
-
matches
|
|
619
|
+
nonlocal matches
|
|
620
|
+
if state == 0:
|
|
621
|
+
matches = match(text)
|
|
634
622
|
return matches[state] if state < len(matches) else None
|
|
635
623
|
|
|
636
624
|
return complete
|
|
637
625
|
|
|
638
626
|
|
|
627
|
+
def _path_matches(text: str) -> list[str]:
|
|
628
|
+
"""Paths completing ``text`` as typed, ``~`` and all, with a / on directories."""
|
|
629
|
+
head, sep, stem = text.rpartition("/")
|
|
630
|
+
directory = Path(os.path.expandvars(head + sep or ".")).expanduser()
|
|
631
|
+
try:
|
|
632
|
+
with os.scandir(directory) as entries:
|
|
633
|
+
found = sorted(
|
|
634
|
+
(entry for entry in entries if entry.name.startswith(stem)),
|
|
635
|
+
key=lambda entry: entry.name,
|
|
636
|
+
)
|
|
637
|
+
return [f"{head}{sep}{entry.name}{'/' if entry.is_dir() else ''}" for entry in found]
|
|
638
|
+
except OSError:
|
|
639
|
+
return []
|
|
640
|
+
|
|
641
|
+
|
|
639
642
|
def pause(message: str = "Press any key to continue") -> None:
|
|
640
643
|
"""Wait for a keypress, in raw mode so any key works (not just Enter)."""
|
|
641
644
|
colors = palette()
|