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.
@@ -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)