backpack-backbone 0.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.
- backbone/__init__.py +30 -0
- backbone/app.py +26 -0
- backbone/datetime_parse.py +224 -0
- backbone/deps.py +98 -0
- backbone/files.py +61 -0
- backbone/keyboard.py +148 -0
- backbone/keys.py +271 -0
- backbone/log.py +61 -0
- backbone/nav.py +17 -0
- backbone/notify.py +36 -0
- backbone/numbering.py +182 -0
- backbone/output.py +216 -0
- backbone/procs.py +60 -0
- backbone/prompt/__init__.py +20 -0
- backbone/prompt/audio.py +496 -0
- backbone/prompt/chrome.py +238 -0
- backbone/prompt/core.py +1562 -0
- backbone/prompt/dates.py +555 -0
- backbone/prompt/keymap.py +143 -0
- backbone/prompt/list_edit.py +974 -0
- backbone/prompt/lists.py +1280 -0
- backbone/prompt/text.py +356 -0
- backbone/prompt/timezone.py +1543 -0
- backbone/prompt/values.py +581 -0
- backbone/terminal_input.py +60 -0
- backbone/timefmt.py +34 -0
- backbone/ui.py +1044 -0
- backpack_backbone-0.2.0.dist-info/METADATA +177 -0
- backpack_backbone-0.2.0.dist-info/RECORD +32 -0
- backpack_backbone-0.2.0.dist-info/WHEEL +5 -0
- backpack_backbone-0.2.0.dist-info/licenses/LICENSE +21 -0
- backpack_backbone-0.2.0.dist-info/top_level.txt +1 -0
backbone/prompt/text.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""One-line text and path entry, and editing a longer text in the system editor."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import subprocess
|
|
7
|
+
from backbone.prompt.core import (
|
|
8
|
+
_get_term_attrs, _set_raw, _restore_term_attrs, _wait_for_keypress, _render_status_bar,
|
|
9
|
+
block_cursor, block_cursor_width, _read_key, _cols, _wrap_bordered_input_lines,
|
|
10
|
+
screen_paint, screen_invalidate, screen_takeover_next,
|
|
11
|
+
)
|
|
12
|
+
from backbone import keys, ui
|
|
13
|
+
from backbone.prompt import chrome
|
|
14
|
+
from backbone.prompt.chrome import CHROME_HANDLED, CHROME_REDRAW, MODE_TOGGLE, append_chrome, consume_chrome, disable_mouse, enable_mouse
|
|
15
|
+
from backbone.prompt.core import C
|
|
16
|
+
from backbone.prompt.core import edit_line
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def text(message: str, default: str = "") -> str | None:
|
|
20
|
+
"""Free-text line editor with cursor movement and wrapping.
|
|
21
|
+
|
|
22
|
+
Enter returns the buffer; Esc or Ctrl-C cancels (None); ^t returns
|
|
23
|
+
MODE_TOGGLE when the toggle is live."""
|
|
24
|
+
buf = list(default)
|
|
25
|
+
pos = len(buf)
|
|
26
|
+
fd = sys.stdin.fileno()
|
|
27
|
+
old = _get_term_attrs(fd)
|
|
28
|
+
result = None
|
|
29
|
+
_hint_cells: dict = {} # clickable hint keys, filled by append_chrome
|
|
30
|
+
|
|
31
|
+
# Track how many physical lines were drawn to clear them later
|
|
32
|
+
prev_lines = 0
|
|
33
|
+
|
|
34
|
+
def _render():
|
|
35
|
+
nonlocal prev_lines
|
|
36
|
+
cols = _cols()
|
|
37
|
+
content = "".join(buf)
|
|
38
|
+
# Frame = ' '(mh) + '│ ' + content + ' │'; content = cols-4 makes the
|
|
39
|
+
# frame span exactly the inter-margin width for an even 2/2 margin.
|
|
40
|
+
content_width = max(1, cols - 4)
|
|
41
|
+
|
|
42
|
+
wrapped_lines = _wrap_bordered_input_lines(content, content_width)
|
|
43
|
+
pre_lines = _wrap_bordered_input_lines(content[:pos], content_width)
|
|
44
|
+
cursor_row = max(0, len(pre_lines) - 1)
|
|
45
|
+
cursor_col = len(pre_lines[-1]) if pre_lines else 0
|
|
46
|
+
|
|
47
|
+
# This prompt owns the screen (it full-clears on entry), so it is laid
|
|
48
|
+
# out absolutely like every other widget: message, input frame, then the
|
|
49
|
+
# hint bar pinned above the now-playing box. The caret is drawn as a block on
|
|
50
|
+
# the character, like every other field: a real terminal caret is at the
|
|
51
|
+
# mercy of the terminal's own cursor style, and could be a thin bar or
|
|
52
|
+
# invisible where the block always reads.
|
|
53
|
+
# No "(^t widget)" suffix on the message: ^t is in the hint bar below,
|
|
54
|
+
# like every other key. The title says what the screen is, not how to
|
|
55
|
+
# leave it.
|
|
56
|
+
out = [f" {C.DIM}{message}{C.RESET}"]
|
|
57
|
+
rows = (wrapped_lines if cursor_row < len(wrapped_lines)
|
|
58
|
+
else wrapped_lines + [""]) # caret parked on a fresh wrap
|
|
59
|
+
for i, line in enumerate(rows):
|
|
60
|
+
shown, used = ((block_cursor(line, cursor_col), block_cursor_width(line, cursor_col))
|
|
61
|
+
if i == cursor_row else (line, len(line)))
|
|
62
|
+
out.append(f" {C.DIM}│{C.RESET} {shown}{' ' * max(0, content_width - used)} "
|
|
63
|
+
f"{C.DIM}│{C.RESET}")
|
|
64
|
+
|
|
65
|
+
pairs = [("↵", "save"), ("esc", "back")]
|
|
66
|
+
if chrome._value_toggle_enabled:
|
|
67
|
+
pairs.append((keys.label("global.raw_text"), chrome._toggle_hint_label))
|
|
68
|
+
append_chrome(out, pairs, _hint_cells)
|
|
69
|
+
|
|
70
|
+
# One diffed frame (no full erase, no newlines): only the rows that
|
|
71
|
+
# actually changed are written, so typing doesn't repaint the screen.
|
|
72
|
+
frame = {i + 1: line for i, line in enumerate([""] * ui.MARGIN_V + out)}
|
|
73
|
+
for r in range(len(frame) + 1, prev_lines + ui.MARGIN_V + 1):
|
|
74
|
+
frame[r] = ""
|
|
75
|
+
screen_paint(frame) # the caret is drawn, not the terminal's own
|
|
76
|
+
|
|
77
|
+
prev_lines = len(out)
|
|
78
|
+
_render_status_bar()
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
_set_raw(fd)
|
|
82
|
+
enable_mouse() # so the hint keys below can be clicked
|
|
83
|
+
screen_takeover_next() # paint over the previous screen, no flash
|
|
84
|
+
_render()
|
|
85
|
+
while True:
|
|
86
|
+
if ui.consume_resize():
|
|
87
|
+
ui.clear_screen()
|
|
88
|
+
_render()
|
|
89
|
+
continue
|
|
90
|
+
if not _wait_for_keypress(0.05): continue
|
|
91
|
+
key = _read_key(fd)
|
|
92
|
+
|
|
93
|
+
_ch = consume_chrome(key, _hint_cells)
|
|
94
|
+
if _ch is CHROME_HANDLED:
|
|
95
|
+
continue
|
|
96
|
+
if _ch is CHROME_REDRAW:
|
|
97
|
+
_render(); continue
|
|
98
|
+
if _ch is not None:
|
|
99
|
+
key = _ch
|
|
100
|
+
|
|
101
|
+
if chrome.is_mode_toggle(key):
|
|
102
|
+
chrome._toggle_carry = "".join(buf)
|
|
103
|
+
return MODE_TOGGLE # type: ignore[return-value]
|
|
104
|
+
if key == 'CTRL_C': result = None; break
|
|
105
|
+
elif key == 'ESC': result = None; break
|
|
106
|
+
elif key == 'ENTER': result = "".join(buf); break
|
|
107
|
+
elif key == 'UP':
|
|
108
|
+
pos = max(0, pos - _cols()); _render()
|
|
109
|
+
elif key == 'DOWN':
|
|
110
|
+
pos = min(len(buf), pos + _cols()); _render()
|
|
111
|
+
elif (new_pos := edit_line(buf, pos, key)) is not None:
|
|
112
|
+
pos = new_pos; _render()
|
|
113
|
+
finally:
|
|
114
|
+
disable_mouse()
|
|
115
|
+
_restore_term_attrs(fd, old)
|
|
116
|
+
ui.clear_screen()
|
|
117
|
+
sys.stdout.write(C.HIDE) # caret was ours; don't leave it blinking
|
|
118
|
+
sys.stdout.flush()
|
|
119
|
+
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def path(message: str, default: str = "") -> str | None:
|
|
124
|
+
"""Filesystem-path editor with Tab-cycling autocomplete against the current directory listing."""
|
|
125
|
+
buf = list(default)
|
|
126
|
+
pos = len(buf)
|
|
127
|
+
fd = sys.stdin.fileno()
|
|
128
|
+
old = _get_term_attrs(fd)
|
|
129
|
+
result = None
|
|
130
|
+
_tab_matches : list = []
|
|
131
|
+
_tab_index = 0
|
|
132
|
+
|
|
133
|
+
# Rows the previous frame used, so leftover tooltip rows are blanked.
|
|
134
|
+
_last_rendered_lines = 1
|
|
135
|
+
_hint_cells: dict = {} # clickable hint keys, filled by append_chrome
|
|
136
|
+
|
|
137
|
+
def _completions(current: str) -> list:
|
|
138
|
+
"""List non-hidden entries in `current`'s directory matching its basename stub, for Tab completion."""
|
|
139
|
+
try:
|
|
140
|
+
expanded = os.path.expanduser(current)
|
|
141
|
+
# Find the root lookup folder depending on whether the path target is a valid directory
|
|
142
|
+
base = expanded if os.path.isdir(expanded) else os.path.dirname(expanded) or "."
|
|
143
|
+
stub = "" if os.path.isdir(expanded) else os.path.basename(expanded)
|
|
144
|
+
return sorted(
|
|
145
|
+
os.path.join(base, e)
|
|
146
|
+
for e in os.listdir(base)
|
|
147
|
+
if e.startswith(stub) and not e.startswith('.')
|
|
148
|
+
)
|
|
149
|
+
except OSError:
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
def _render():
|
|
153
|
+
nonlocal _last_rendered_lines
|
|
154
|
+
cols = _cols()
|
|
155
|
+
content = "".join(buf)
|
|
156
|
+
prefix = " │ "
|
|
157
|
+
# cols-4 makes the ' │ … │' frame span the inter-margin width (even 2/2).
|
|
158
|
+
max_w = max(1, cols - 4)
|
|
159
|
+
|
|
160
|
+
if pos >= max_w:
|
|
161
|
+
# Scrolled: keep one column spare so the block has somewhere to sit
|
|
162
|
+
# rather than straddling the frame's right border.
|
|
163
|
+
display = content[pos - max_w + 1: pos]
|
|
164
|
+
disp_pos = len(display)
|
|
165
|
+
else:
|
|
166
|
+
display = content[:max_w]
|
|
167
|
+
disp_pos = pos
|
|
168
|
+
|
|
169
|
+
cursor_col = len(prefix) + disp_pos
|
|
170
|
+
shown = block_cursor(display, disp_pos)
|
|
171
|
+
pad = max(0, max_w - block_cursor_width(display, disp_pos))
|
|
172
|
+
|
|
173
|
+
render_stream = [
|
|
174
|
+
f" {C.DIM}{message}{C.RESET}\r\n",
|
|
175
|
+
f" {C.DIM}│{C.RESET} {shown}{' ' * pad} {C.DIM}│{C.RESET}"
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
lines_count = 2
|
|
179
|
+
|
|
180
|
+
# Do not output autocomplete options when at a subdirectory juncture or when empty
|
|
181
|
+
should_show_hints = content and not content.endswith('/') and not content.endswith(os.path.sep)
|
|
182
|
+
|
|
183
|
+
visible_matches = []
|
|
184
|
+
if should_show_hints and _tab_matches:
|
|
185
|
+
stub = os.path.basename(content)
|
|
186
|
+
for m in _tab_matches:
|
|
187
|
+
name = os.path.basename(m.rstrip('/'))
|
|
188
|
+
if name.startswith(stub):
|
|
189
|
+
visible_matches.append(m)
|
|
190
|
+
|
|
191
|
+
if visible_matches:
|
|
192
|
+
render_stream.append("\r\n\033[K")
|
|
193
|
+
lines_count += 1
|
|
194
|
+
|
|
195
|
+
start_pad = min(cursor_col, max(0, cols - 35))
|
|
196
|
+
render_stream.append(" " * start_pad)
|
|
197
|
+
|
|
198
|
+
tooltip_parts = []
|
|
199
|
+
current_len = start_pad
|
|
200
|
+
|
|
201
|
+
for idx, match in enumerate(visible_matches[:5]):
|
|
202
|
+
name = os.path.basename(match.rstrip('/'))
|
|
203
|
+
if os.path.isdir(match):
|
|
204
|
+
name += "/"
|
|
205
|
+
|
|
206
|
+
if idx == (_tab_index % len(visible_matches)):
|
|
207
|
+
item_str = f"{C.INVERT}{C.BOLD}{name}{C.RESET}"
|
|
208
|
+
visible_len = len(name)
|
|
209
|
+
else:
|
|
210
|
+
item_str = f"{C.DIM}{name}{C.RESET}"
|
|
211
|
+
visible_len = len(name)
|
|
212
|
+
|
|
213
|
+
if current_len + visible_len + 2 > cols:
|
|
214
|
+
render_stream.append(" ".join(tooltip_parts) + "\r\n\033[K" + " " * start_pad)
|
|
215
|
+
lines_count += 1
|
|
216
|
+
tooltip_parts = [item_str]
|
|
217
|
+
current_len = start_pad + visible_len
|
|
218
|
+
else:
|
|
219
|
+
tooltip_parts.append(item_str)
|
|
220
|
+
current_len += visible_len + 2
|
|
221
|
+
|
|
222
|
+
if tooltip_parts:
|
|
223
|
+
render_stream.append(" ".join(tooltip_parts))
|
|
224
|
+
|
|
225
|
+
if len(visible_matches) > 5:
|
|
226
|
+
render_stream.append(f" {C.DIM}(+{len(visible_matches)-5}){C.RESET}")
|
|
227
|
+
|
|
228
|
+
_prev_rendered = _last_rendered_lines
|
|
229
|
+
_last_rendered_lines = lines_count
|
|
230
|
+
|
|
231
|
+
# Laid out absolutely, like every other screen: the completion tooltip
|
|
232
|
+
# still rides just under the frame, but the hint bar is pinned above the
|
|
233
|
+
# now-playing box instead of trailing whatever the tooltip left behind.
|
|
234
|
+
out = "".join(render_stream).split("\r\n")
|
|
235
|
+
pairs = [("↵", "save"), ("tab/⇧tab", "complete"), ("esc", "back")]
|
|
236
|
+
append_chrome(out, pairs, _hint_cells)
|
|
237
|
+
|
|
238
|
+
# One diffed frame; see text() above.
|
|
239
|
+
frame = {i + 1: line for i, line in enumerate([""] * ui.MARGIN_V + out)}
|
|
240
|
+
for r in range(len(frame) + 1, _prev_rendered + ui.MARGIN_V + 2):
|
|
241
|
+
frame[r] = ""
|
|
242
|
+
screen_paint(frame) # the caret is drawn, not the terminal's own
|
|
243
|
+
_render_status_bar()
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
_set_raw(fd)
|
|
247
|
+
enable_mouse() # so the hint keys below can be clicked
|
|
248
|
+
screen_takeover_next() # paint over the previous screen, no flash
|
|
249
|
+
_tab_matches = _completions("".join(buf))
|
|
250
|
+
_render()
|
|
251
|
+
|
|
252
|
+
while True:
|
|
253
|
+
if ui.consume_resize(): _render()
|
|
254
|
+
if not _wait_for_keypress(0.05): continue
|
|
255
|
+
key = _read_key(fd)
|
|
256
|
+
|
|
257
|
+
_ch = consume_chrome(key, _hint_cells)
|
|
258
|
+
if _ch is CHROME_HANDLED:
|
|
259
|
+
continue
|
|
260
|
+
if _ch is CHROME_REDRAW:
|
|
261
|
+
_render(); continue
|
|
262
|
+
if _ch is not None:
|
|
263
|
+
key = _ch
|
|
264
|
+
|
|
265
|
+
if key == 'CTRL_C':
|
|
266
|
+
result = None; break
|
|
267
|
+
elif key == 'ESC':
|
|
268
|
+
result = None; break
|
|
269
|
+
elif key == 'ENTER':
|
|
270
|
+
result = "".join(buf); break
|
|
271
|
+
|
|
272
|
+
elif key in ('TAB', 'BACKTAB'):
|
|
273
|
+
current_text = "".join(buf)
|
|
274
|
+
stub = os.path.basename(current_text) if (current_text and not current_text.endswith('/')) else ""
|
|
275
|
+
|
|
276
|
+
visible_matches = [m for m in _tab_matches if os.path.basename(m.rstrip('/')).startswith(stub)] if stub else _tab_matches
|
|
277
|
+
|
|
278
|
+
if visible_matches:
|
|
279
|
+
if key == 'BACKTAB':
|
|
280
|
+
_tab_index -= 2 # step back past the one just offered
|
|
281
|
+
completed = visible_matches[_tab_index % len(visible_matches)]
|
|
282
|
+
if os.path.isdir(completed) and not completed.endswith("/"):
|
|
283
|
+
completed += "/"
|
|
284
|
+
|
|
285
|
+
buf[:] = list(completed)
|
|
286
|
+
pos = len(buf)
|
|
287
|
+
_tab_index += 1
|
|
288
|
+
|
|
289
|
+
_tab_matches = _completions("".join(buf))
|
|
290
|
+
_render()
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
else:
|
|
294
|
+
before = list(buf)
|
|
295
|
+
new_pos = edit_line(buf, pos, key)
|
|
296
|
+
if new_pos is not None:
|
|
297
|
+
pos = new_pos
|
|
298
|
+
if buf != before: # the text changed: fresh completions
|
|
299
|
+
_tab_matches = _completions("".join(buf))
|
|
300
|
+
_tab_index = 0
|
|
301
|
+
_render()
|
|
302
|
+
|
|
303
|
+
finally:
|
|
304
|
+
disable_mouse()
|
|
305
|
+
_restore_term_attrs(fd, old)
|
|
306
|
+
ui.clear_screen()
|
|
307
|
+
sys.stdout.write(C.HIDE) # caret was ours; don't leave it blinking
|
|
308
|
+
sys.stdout.flush()
|
|
309
|
+
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
_EDITOR_FALLBACKS = ['micro', 'nano', 'vim', 'vi', 'emacs']
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _find_editor() -> str | None:
|
|
317
|
+
"""Return the editor to use: $EDITOR if set and found, else first available fallback."""
|
|
318
|
+
import shutil
|
|
319
|
+
env_editor = os.environ.get('EDITOR', '').strip()
|
|
320
|
+
if env_editor:
|
|
321
|
+
cmd = env_editor.split()[0]
|
|
322
|
+
if shutil.which(cmd):
|
|
323
|
+
return env_editor
|
|
324
|
+
for ed in _EDITOR_FALLBACKS:
|
|
325
|
+
if shutil.which(ed):
|
|
326
|
+
return ed
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def system_editor_edit(initial_text: str) -> str | None:
|
|
331
|
+
"""Open system editor for long text."""
|
|
332
|
+
with tempfile.NamedTemporaryFile(suffix=".txt", mode='w+', encoding='utf-8', delete=False) as tf:
|
|
333
|
+
tf.write(initial_text)
|
|
334
|
+
temp_path = tf.name
|
|
335
|
+
try:
|
|
336
|
+
editor = _find_editor() or 'nano'
|
|
337
|
+
sys.stdout.write("\033[?7h"); sys.stdout.flush() # the editor expects wrapping on
|
|
338
|
+
try:
|
|
339
|
+
subprocess.run(editor.split() + [temp_path], check=True)
|
|
340
|
+
finally:
|
|
341
|
+
sys.stdout.write("\033[?7l") # ours again (see enter_alt_screen)
|
|
342
|
+
# The editor owned the screen and left its own cursor visible: forget what
|
|
343
|
+
# we thought was on screen and hide the cursor again before the caller
|
|
344
|
+
# repaints, so no caret is left blinking over our frame.
|
|
345
|
+
screen_invalidate()
|
|
346
|
+
sys.stdout.write(C.HIDE)
|
|
347
|
+
sys.stdout.flush()
|
|
348
|
+
with open(temp_path, 'r', encoding='utf-8') as f:
|
|
349
|
+
result = f.read().strip()
|
|
350
|
+
return result if result else None
|
|
351
|
+
except (OSError, subprocess.CalledProcessError) as e:
|
|
352
|
+
ui.show_status(f"Error launching editor: {e}")
|
|
353
|
+
return None
|
|
354
|
+
finally:
|
|
355
|
+
if os.path.exists(temp_path):
|
|
356
|
+
os.remove(temp_path)
|