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/core.py
ADDED
|
@@ -0,0 +1,1562 @@
|
|
|
1
|
+
"""Terminal primitives shared across prompt widgets."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import os
|
|
6
|
+
import math
|
|
7
|
+
import textwrap
|
|
8
|
+
import time
|
|
9
|
+
import select as _sel
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from backbone import keys, ui
|
|
13
|
+
from backbone.log import log, enabled as _logging, quietly
|
|
14
|
+
C = ui.Colors
|
|
15
|
+
|
|
16
|
+
_IS_WINDOWS = os.name == "nt"
|
|
17
|
+
|
|
18
|
+
_COLUMNS_MAX_WIDTH = 160 # cap effective width for table layout even on ultra-wide terminals
|
|
19
|
+
_EDGE_MARGIN = 2 # right-side padding for pinned columns
|
|
20
|
+
_MIN_COL_FLOOR = 6 # a squeezed column shrinks to at most this before it stops giving up space
|
|
21
|
+
_MIN_PIN_GAP = 2 # reserved breathing space between the left block and right-pinned columns
|
|
22
|
+
|
|
23
|
+
tty: Any
|
|
24
|
+
termios: Any
|
|
25
|
+
msvcrt: Any
|
|
26
|
+
|
|
27
|
+
if _IS_WINDOWS:
|
|
28
|
+
import msvcrt
|
|
29
|
+
else:
|
|
30
|
+
import tty
|
|
31
|
+
import termios
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_term_attrs(fd: int):
|
|
35
|
+
"""Current termios attributes for fd, or None on Windows."""
|
|
36
|
+
return None if _IS_WINDOWS else termios.tcgetattr(fd)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _set_raw(fd: int) -> None:
|
|
40
|
+
"""Put the terminal into raw mode (no-op on Windows)."""
|
|
41
|
+
if not _IS_WINDOWS:
|
|
42
|
+
tty.setraw(fd)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _restore_term_attrs(fd: int, old):
|
|
46
|
+
"""Restore terminal attributes captured before raw mode."""
|
|
47
|
+
if not _IS_WINDOWS and old is not None:
|
|
48
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_footer_prev_h = [0]
|
|
52
|
+
_footer_prev_lines: list = [None]
|
|
53
|
+
_footer_prev_sig: list = [object()] # last-drawn track identity (never == a real sig)
|
|
54
|
+
_footer_last_draw = [0.0]
|
|
55
|
+
_status_prev_active = [False] # was a background task shown last idle tick?
|
|
56
|
+
|
|
57
|
+
# Self-pipe so background threads can wake the menu poll to repaint the box the
|
|
58
|
+
# instant playback state changes; see ui.pulse_footer(). The
|
|
59
|
+
# poll's select() watches the read end alongside stdin; a pulse makes it return
|
|
60
|
+
# immediately and repaint, rather than waiting on the next keystroke or timeout.
|
|
61
|
+
try:
|
|
62
|
+
_wake_r, _wake_w = os.pipe()
|
|
63
|
+
os.set_blocking(_wake_r, False)
|
|
64
|
+
os.set_blocking(_wake_w, False)
|
|
65
|
+
except OSError: # no pipes (e.g. odd sandbox): degrade gracefully
|
|
66
|
+
_wake_r = _wake_w = -1
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _poke_footer_wake() -> None:
|
|
70
|
+
"""Write one byte to the wake pipe (coalesced by the reader; never blocks)."""
|
|
71
|
+
if _wake_w >= 0:
|
|
72
|
+
try:
|
|
73
|
+
os.write(_wake_w, b'.')
|
|
74
|
+
except (BlockingIOError, OSError):
|
|
75
|
+
pass # pipe full already → a wake is pending anyway
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if _wake_r >= 0:
|
|
79
|
+
ui.set_footer_waker(_poke_footer_wake)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Persistent screen model.
|
|
84
|
+
#
|
|
85
|
+
# One entry per screen row holding what is currently displayed there, shared by
|
|
86
|
+
# every writer (widget frames, the now-playing box, the status bar). A repaint
|
|
87
|
+
# writes only the rows whose content actually changed and never erases a row
|
|
88
|
+
# before rewriting it: erasing to the end of the screen and redrawing everything
|
|
89
|
+
# makes the screen flicker and the now-playing box blink on every keystroke. Rows are also written *absolutely*, with no newlines,
|
|
90
|
+
# so a line-buffered stdout cannot flush a half-drawn frame.
|
|
91
|
+
_screen: dict[int, str] = {}
|
|
92
|
+
# The terminal size the model was painted at. A resize reflows what is on
|
|
93
|
+
# screen, so the model no longer describes it: the next paint wipes the screen
|
|
94
|
+
# and repaints every row, whether or not the screen's own code thought to clear.
|
|
95
|
+
_screen_size: list = [None]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def screen_invalidate() -> None:
|
|
99
|
+
"""Forget what is on screen: after a full clear, a resize, or a write by
|
|
100
|
+
something that doesn't go through here (the player view). A clear at a new
|
|
101
|
+
size (a screen answering a resize) counts as the resize wipe too."""
|
|
102
|
+
size = ui.get_terminal_size()
|
|
103
|
+
if _screen_size[0] is not None and size != _screen_size[0]:
|
|
104
|
+
_note_resize(_screen_size[0], size)
|
|
105
|
+
_screen_size[0] = size
|
|
106
|
+
_screen.clear()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _note_resize(was: tuple, size: tuple) -> None:
|
|
110
|
+
"""Log a resize: how long after the terminal reported it we're repainting,
|
|
111
|
+
and which rows of the old frame were wider than the new window, the ones
|
|
112
|
+
the terminal rewrapped before we could redraw."""
|
|
113
|
+
if not _logging():
|
|
114
|
+
return
|
|
115
|
+
wide = sorted(r for r, key in _screen.items() if _row_width(key.split("\x00", 1)[0]) > size[0])
|
|
116
|
+
log.debug("resize %sx%s -> %sx%s, repaint %.0f ms after the signal; %d old rows "
|
|
117
|
+
"wider than the new width (rewrapped by the terminal): %s",
|
|
118
|
+
was[0], was[1], size[0], size[1], ui.ms_since_resize_signal(),
|
|
119
|
+
len(wide), wide[:20])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _resize_wipe() -> str:
|
|
123
|
+
""""" normally; after a terminal resize, a full clear (and a forgotten
|
|
124
|
+
model), so the frame being painted repaints everything."""
|
|
125
|
+
size = ui.get_terminal_size()
|
|
126
|
+
if size == _screen_size[0]:
|
|
127
|
+
return ""
|
|
128
|
+
was, _screen_size[0] = _screen_size[0], size
|
|
129
|
+
if was is not None:
|
|
130
|
+
_note_resize(was, size)
|
|
131
|
+
_screen.clear()
|
|
132
|
+
return "\033[H\033[2J" if was is not None else ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _row_width(text: str) -> int:
|
|
136
|
+
"""Visible width of a painted row, ignoring colour codes."""
|
|
137
|
+
return ui.visual_len(ui.strip_ansi(text))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _register_screen_hooks() -> None:
|
|
141
|
+
"""Let `ui.clear_screen()` (and the alt-screen switch) drop the model."""
|
|
142
|
+
ui.set_screen_invalidator(screen_invalidate)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_takeover_pending = [False]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def screen_takeover_next() -> None:
|
|
149
|
+
"""Take the screen over on the next frame *without* clearing it first.
|
|
150
|
+
|
|
151
|
+
Called where a widget would otherwise clear on entry: the next paint
|
|
152
|
+
overwrites the rows it needs and blanks whatever the previous screen left
|
|
153
|
+
behind, in the same flush, so there is no blank flash between screens. A
|
|
154
|
+
clear is only needed when the terminal reflowed (resize) or something
|
|
155
|
+
painted outside this model.
|
|
156
|
+
"""
|
|
157
|
+
_takeover_pending[0] = True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _takeover_rows(frame: dict) -> dict:
|
|
161
|
+
"""Add blanks for rows the previous screen owned that `frame` doesn't."""
|
|
162
|
+
if not _takeover_pending[0]:
|
|
163
|
+
return frame
|
|
164
|
+
_takeover_pending[0] = False
|
|
165
|
+
out = dict(frame)
|
|
166
|
+
for row in _screen:
|
|
167
|
+
out.setdefault(row, "")
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def screen_rows() -> set:
|
|
172
|
+
"""Every row the painter currently believes it knows the content of."""
|
|
173
|
+
return set(_screen)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def screen_forget_rows(first: int, last: int) -> None:
|
|
177
|
+
"""Forget rows `first`..`last` inclusive (another writer owns them now)."""
|
|
178
|
+
for r in range(first, last + 1):
|
|
179
|
+
_screen.pop(r, None)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def screen_row_paint(row: int, text: str, extra: str = "") -> str:
|
|
183
|
+
"""The escape string that paints one row as `text` with `extra` layered on
|
|
184
|
+
top, or "" when the row already reads exactly that way.
|
|
185
|
+
|
|
186
|
+
`extra` is for absolute overlays that write a few columns of a row something
|
|
187
|
+
else owns (the volume bar sits on the album art's rows). Both layers are part
|
|
188
|
+
of the row's identity, so a row repaints when *either* changes, and a row
|
|
189
|
+
whose overlay went away is erased rather than keeping stale glyphs. A blank
|
|
190
|
+
row is content too: "" differs from anything previously drawn there.
|
|
191
|
+
"""
|
|
192
|
+
wipe = _resize_wipe()
|
|
193
|
+
key = f"{text}\x00{extra}"
|
|
194
|
+
if _screen.get(row) == key:
|
|
195
|
+
return ""
|
|
196
|
+
_screen[row] = key
|
|
197
|
+
if _logging() and _row_width(text) > (_screen_size[0] or (0, 0))[0]:
|
|
198
|
+
log.warning("row %d painted %d wide in a %d-column window: %r", row,
|
|
199
|
+
_row_width(text), _screen_size[0][0], ui.strip_ansi(text)[:80])
|
|
200
|
+
return f"{wipe}\033[{row};1H\033[2K{text}{extra}"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def screen_row_segment(row: int, text: str) -> str:
|
|
204
|
+
"""Paint one row of plain text (no overlay); see `screen_row_paint`."""
|
|
205
|
+
return screen_row_paint(row, text)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def screen_paint(rows: dict, *, cursor: tuple | None = None,
|
|
209
|
+
hide_cursor: bool = True, save_cursor: bool = False) -> None:
|
|
210
|
+
"""Paint `rows` ({1-based row: text}) as one buffered, single-syscall frame.
|
|
211
|
+
|
|
212
|
+
Unchanged rows cost nothing. `cursor` places the caret and shows it (text
|
|
213
|
+
inputs); `save_cursor` wraps the frame in DEC save/restore so a caret
|
|
214
|
+
elsewhere is left alone (background repaints).
|
|
215
|
+
"""
|
|
216
|
+
rows = _takeover_rows(rows)
|
|
217
|
+
parts: list[str] = []
|
|
218
|
+
for row in sorted(rows):
|
|
219
|
+
seg = screen_row_segment(row, rows[row])
|
|
220
|
+
if seg:
|
|
221
|
+
parts.append(seg)
|
|
222
|
+
if not parts:
|
|
223
|
+
return # nothing changed: draw nothing at all
|
|
224
|
+
body = "".join(parts)
|
|
225
|
+
if save_cursor:
|
|
226
|
+
out = "\0337" + body + "\0338"
|
|
227
|
+
else:
|
|
228
|
+
out = (C.HIDE if hide_cursor else "") + body
|
|
229
|
+
if cursor is not None:
|
|
230
|
+
out += f"\033[{cursor[0]};{cursor[1]}H" + C.SHOW
|
|
231
|
+
sys.stdout.write(out)
|
|
232
|
+
sys.stdout.flush()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _footer_box_str(rows: int, lines: list) -> str:
|
|
236
|
+
"""Escape string that draws the now-playing box in the rows just above the
|
|
237
|
+
breadcrumb, clearing any band a taller previous box left behind. Updates the
|
|
238
|
+
shared cache so the widget render and the idle tick agree on what's shown."""
|
|
239
|
+
if rows <= 1:
|
|
240
|
+
_footer_prev_h[0] = 0
|
|
241
|
+
_footer_prev_lines[0] = []
|
|
242
|
+
_footer_prev_sig[0] = ui.footer_signature()
|
|
243
|
+
return ""
|
|
244
|
+
|
|
245
|
+
max_box_rows = max(0, rows - 1)
|
|
246
|
+
lines = lines[-max_box_rows:]
|
|
247
|
+
h = len(lines)
|
|
248
|
+
band = max(_footer_prev_h[0], h)
|
|
249
|
+
# Rows the box no longer covers are blanked; rows it does are painted, both
|
|
250
|
+
# through the shared screen model, so an unchanged box emits nothing at all
|
|
251
|
+
# and a shrinking one clears exactly the rows it gave up.
|
|
252
|
+
wanted: dict[int, str] = {}
|
|
253
|
+
for k in range(band):
|
|
254
|
+
row = rows - band + k
|
|
255
|
+
if 1 <= row < rows:
|
|
256
|
+
wanted[row] = ""
|
|
257
|
+
for k in range(h):
|
|
258
|
+
row = rows - h + k
|
|
259
|
+
if 1 <= row < rows:
|
|
260
|
+
wanted[row] = lines[k]
|
|
261
|
+
parts: list[str] = []
|
|
262
|
+
for row in sorted(wanted):
|
|
263
|
+
seg = screen_row_segment(row, wanted[row])
|
|
264
|
+
if seg:
|
|
265
|
+
parts.append(seg)
|
|
266
|
+
_footer_prev_h[0] = h
|
|
267
|
+
_footer_prev_lines[0] = lines
|
|
268
|
+
_footer_prev_sig[0] = ui.footer_signature()
|
|
269
|
+
return "".join(parts)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def footer_height_for_layout() -> int:
|
|
273
|
+
"""Rows the now-playing box occupies, as the frame layout should assume."""
|
|
274
|
+
return max(_footer_prev_h[0], ui.footer_height())
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def footer_box_segment() -> str:
|
|
278
|
+
"""The box draw-string for embedding in a widget's own atomic flush (so
|
|
279
|
+
navigation redraws it alongside the list instead of leaving it flashed out)."""
|
|
280
|
+
rows = ui.get_terminal_height()
|
|
281
|
+
if rows <= 1:
|
|
282
|
+
return ""
|
|
283
|
+
cols = ui.get_terminal_width()
|
|
284
|
+
return _footer_box_str(rows, ui.footer_lines(cols))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def invalidate_footer_box() -> None:
|
|
288
|
+
"""Drop the last-drawn box cache so the next idle tick repaints unconditionally.
|
|
289
|
+
Used on focus-in: while a window is unfocused the terminal may not paint
|
|
290
|
+
our box writes, yet the cache advances as if it had, leaving the box stale
|
|
291
|
+
after refocus until an interaction. Forcing a repaint fixes it without a click."""
|
|
292
|
+
_footer_prev_lines[0] = None
|
|
293
|
+
_footer_prev_sig[0] = object() # sentinel: never equal to a real signature
|
|
294
|
+
_footer_last_draw[0] = 0.0 # let the next poll repaint immediately
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _render_footer_bar() -> None:
|
|
298
|
+
"""Idle-tick refresh so the clock/progress advance (and a background track
|
|
299
|
+
change lands) when nothing else redraws. Repaints when either the track
|
|
300
|
+
identity or the styled rows changed, so an idle screen never flickers yet a
|
|
301
|
+
new song is never missed."""
|
|
302
|
+
rows = ui.get_terminal_height()
|
|
303
|
+
cols = ui.get_terminal_width()
|
|
304
|
+
lines = ui.footer_lines(cols)
|
|
305
|
+
if len(lines) != _footer_prev_h[0]:
|
|
306
|
+
# The box appeared or vanished: the menu must re-reserve rows for it.
|
|
307
|
+
ui.mark_footer_layout_dirty()
|
|
308
|
+
if ui.footer_signature() == _footer_prev_sig[0] and lines == _footer_prev_lines[0]:
|
|
309
|
+
return
|
|
310
|
+
seg = _footer_box_str(rows, lines)
|
|
311
|
+
if seg: # unchanged rows produce nothing to write
|
|
312
|
+
sys.stdout.write("\0337" + seg + "\0338")
|
|
313
|
+
sys.stdout.flush()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _wait_for_keypress(timeout: float = 0.05) -> bool:
|
|
317
|
+
"""Block up to `timeout` seconds for a keypress; return whether one arrived.
|
|
318
|
+
|
|
319
|
+
Also refreshes the now-playing box at ~4 Hz so background-audio status
|
|
320
|
+
stays live on every widget/menu without each one needing its own tick."""
|
|
321
|
+
now = time.time()
|
|
322
|
+
if now - _footer_last_draw[0] >= 0.12:
|
|
323
|
+
_footer_last_draw[0] = now
|
|
324
|
+
with quietly():
|
|
325
|
+
_render_footer_bar()
|
|
326
|
+
# Keep the background-activity notice live: while a task is running the
|
|
327
|
+
# status bar is re-stamped each tick so it stays up for the whole job and
|
|
328
|
+
# its cyan ● pulses; one extra redraw after the last task clears the bar.
|
|
329
|
+
active = ui.has_background_tasks()
|
|
330
|
+
if active or _status_prev_active[0]:
|
|
331
|
+
with quietly():
|
|
332
|
+
_render_status_bar()
|
|
333
|
+
_status_prev_active[0] = active
|
|
334
|
+
if _IS_WINDOWS:
|
|
335
|
+
end = time.time() + timeout
|
|
336
|
+
while time.time() < end:
|
|
337
|
+
if msvcrt.kbhit():
|
|
338
|
+
return True
|
|
339
|
+
time.sleep(0.01)
|
|
340
|
+
return False
|
|
341
|
+
watch = [sys.stdin, _wake_r] if _wake_r >= 0 else [sys.stdin]
|
|
342
|
+
ready = _sel.select(watch, [], [], timeout)[0]
|
|
343
|
+
if _wake_r >= 0 and _wake_r in ready:
|
|
344
|
+
try:
|
|
345
|
+
os.read(_wake_r, 4096) # drain all coalesced pulses
|
|
346
|
+
except OSError:
|
|
347
|
+
pass
|
|
348
|
+
_footer_last_draw[0] = time.time() # this pulse counts as the tick
|
|
349
|
+
with quietly():
|
|
350
|
+
_render_footer_bar() # repaint immediately on a state change
|
|
351
|
+
return sys.stdin in ready # a wake alone is not a keypress
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _cols() -> int:
|
|
356
|
+
"""Usable terminal width after subtracting the horizontal margins."""
|
|
357
|
+
return max(1, ui.get_terminal_width() - 2 * ui.MARGIN_H)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# --- Hint bar visibility ------------------------------------------------------
|
|
362
|
+
# One switch for every screen's hint bar, off until turned on (`?`, Ctrl-/ where
|
|
363
|
+
# `?` is typed, or a click on the corner toggle each screen shows on its top
|
|
364
|
+
# line), remembered between runs.
|
|
365
|
+
# Kept in its own small file rather than config.json: screens hold a loaded
|
|
366
|
+
# config and save it back later, which would quietly undo a toggle made meanwhile.
|
|
367
|
+
HINTS_CLICK = '\x00hints' # the key a click on the corner toggle replays
|
|
368
|
+
keys.define("global", "Everywhere", [
|
|
369
|
+
# Wherever it isn't typed or bound; Ctrl-/ (the same key with Ctrl) is the
|
|
370
|
+
# way where it is, and works everywhere.
|
|
371
|
+
("help", ("?",), "show or hide the key hints"),
|
|
372
|
+
("help_typed", ("\x1f",), "show or hide the key hints, in a text field too"),
|
|
373
|
+
], within=())
|
|
374
|
+
_hints_on: list = [None] # None until first read
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _hints_file():
|
|
378
|
+
from backbone import app
|
|
379
|
+
return app.config_dir / "hints_on"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def hints_visible() -> bool:
|
|
383
|
+
"""Whether hint bars are shown."""
|
|
384
|
+
if _hints_on[0] is None:
|
|
385
|
+
try:
|
|
386
|
+
_hints_on[0] = _hints_file().exists()
|
|
387
|
+
except Exception:
|
|
388
|
+
_hints_on[0] = False
|
|
389
|
+
return _hints_on[0]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def toggle_hints() -> None:
|
|
393
|
+
"""Show or hide every hint bar, and remember it."""
|
|
394
|
+
_hints_on[0] = not hints_visible()
|
|
395
|
+
try:
|
|
396
|
+
f = _hints_file()
|
|
397
|
+
if _hints_on[0]:
|
|
398
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
399
|
+
f.touch()
|
|
400
|
+
else:
|
|
401
|
+
f.unlink(missing_ok=True)
|
|
402
|
+
except OSError:
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def is_hints_key(key: str, key_free: bool) -> bool:
|
|
407
|
+
"""Whether `key` toggles the hints: a click on the corner, Ctrl-/, or `?`
|
|
408
|
+
where the screen leaves `?` free (not typed or bound)."""
|
|
409
|
+
return (key == HINTS_CLICK or keys.pressed(key, "global.help_typed")
|
|
410
|
+
or (key_free and keys.pressed(key, "global.help")))
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def help_corner_text(help_key: bool = True, shown: bool | None = None) -> tuple[str, int]:
|
|
414
|
+
"""The header toggle, styled, and its width: `[?] help` / `[?] hide help`,
|
|
415
|
+
or `[^/] …` where `?` is typed (a text field) and Ctrl-/ is the key.
|
|
416
|
+
`shown`: the hints' state to describe (default: as they are now)."""
|
|
417
|
+
key = keys.label("global.help" if help_key else "global.help_typed", first=True) or " "
|
|
418
|
+
label = "hide help" if (hints_visible() if shown is None else shown) else "help"
|
|
419
|
+
return (f"{C.RESET}{C.DIM}[{C.RESET}{C.BOLD}{key}{C.RESET}{C.DIM}] {label}{C.RESET}",
|
|
420
|
+
3 + len(key) + len(label))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _toggle_variants() -> list[tuple[str, int]]:
|
|
424
|
+
"""Every form the toggle can take: either key, either state."""
|
|
425
|
+
return [help_corner_text(k, shown) for k in (True, False) for shown in (True, False)]
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def help_toggle_width() -> int:
|
|
429
|
+
"""The room a header keeps for the toggle: its widest form, so swapping in
|
|
430
|
+
another (the hints switched, or the key that works here) never moves the row."""
|
|
431
|
+
return max(w for _t, w in _toggle_variants())
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def add_help_corner(line: str, row: int, cells: dict, help_key: bool = False) -> str:
|
|
435
|
+
"""`line` (a screen's top line, drawn on screen row `row`) with the toggle
|
|
436
|
+
right-aligned on it, clipping the line if the two would meet. Only the key
|
|
437
|
+
is clickable (a click replays HINTS_CLICK). `help_key`: pressing `?` toggles
|
|
438
|
+
here; elsewhere `?` is typed or bound, and the toggle names Ctrl-/."""
|
|
439
|
+
text, width = help_corner_text(help_key)
|
|
440
|
+
col = max(1, ui.get_terminal_width() - ui.MARGIN_H - width + 1)
|
|
441
|
+
room = col - 2 # keep one blank column before it
|
|
442
|
+
body = line if ui.visual_len(ui.strip_ansi(line)) <= room else _clip_ansi(line, room)
|
|
443
|
+
pad = max(1, col - 1 - ui.visual_len(ui.strip_ansi(body)))
|
|
444
|
+
cells[(row, col + 1)] = HINTS_CLICK # the key inside "[…]"
|
|
445
|
+
if help_key:
|
|
446
|
+
cells['__help_key__'] = True # consume_chrome: `?` toggles here
|
|
447
|
+
return f"{body}{C.RESET}{' ' * pad}{text}"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def rounded_header(title: str, detail: str = "", right: str = "",
|
|
451
|
+
subtitle: str | None = None) -> list[str]:
|
|
452
|
+
"""The app's boxed header for a file (or a set of them): bold `title`, dim
|
|
453
|
+
`detail` after it (" · artist"), dim facts `right`-aligned, and the hints
|
|
454
|
+
toggle inline at the far right of the same row, then an optional dim
|
|
455
|
+
`subtitle` row and a blank row. When space runs out the detail is trimmed
|
|
456
|
+
first, then the facts dropped, then the title trimmed; the toggle stays."""
|
|
457
|
+
vl = ui.visual_len
|
|
458
|
+
mh = ui.MARGIN_H
|
|
459
|
+
inner = max(12, ui.get_terminal_width() - 2 * mh - 4)
|
|
460
|
+
# Room for the toggle's widest form, so place_help_toggle can swap in the
|
|
461
|
+
# one that fits now without the row growing (see help_toggle_width).
|
|
462
|
+
toggle, tw = help_corner_text()
|
|
463
|
+
wide = help_toggle_width()
|
|
464
|
+
toggle, tw = " " * (wide - tw) + toggle, wide
|
|
465
|
+
|
|
466
|
+
def _fit(text: str, n: int) -> str:
|
|
467
|
+
return text if vl(text) <= n else (text[:max(0, n - 1)] + "…" if n > 1 else "")
|
|
468
|
+
|
|
469
|
+
tail = tw + (vl(right) + 2 if right else 0)
|
|
470
|
+
if right and vl(title) + tail + 1 > inner:
|
|
471
|
+
right, tail = "", tw # no room for the facts
|
|
472
|
+
avail = max(1, inner - tail - 1)
|
|
473
|
+
title = _fit(title, avail)
|
|
474
|
+
detail = _fit(detail, avail - vl(title)) if avail - vl(title) > 5 else ""
|
|
475
|
+
left = f"{C.BOLD}{title}{C.RESET}{C.DIM}{detail}{C.RESET}"
|
|
476
|
+
gap = max(1, inner - vl(title) - vl(detail) - tail)
|
|
477
|
+
facts = f"{C.DIM}{right}{C.RESET} " if right else ""
|
|
478
|
+
edge = f"{' ' * mh}{C.DIM}"
|
|
479
|
+
lines = [f"{edge}╭{'─' * (inner + 2)}╮{C.RESET}",
|
|
480
|
+
f"{edge}│{C.RESET} {left}{' ' * gap}{facts}{toggle} {C.DIM}│{C.RESET}"]
|
|
481
|
+
if subtitle:
|
|
482
|
+
sub = _fit(subtitle, inner)
|
|
483
|
+
lines.append(f"{edge}│{C.RESET} {C.DIM}{sub}{' ' * (inner - vl(sub))}{C.RESET} {C.DIM}│{C.RESET}")
|
|
484
|
+
lines += [f"{edge}╰{'─' * (inner + 2)}╯{C.RESET}", ""]
|
|
485
|
+
return lines
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def place_help_toggle(out: list, first_row: int, cells: dict, help_key: bool = False) -> None:
|
|
489
|
+
"""Make the hints toggle on a screen clickable: a header that already
|
|
490
|
+
carries it (rounded_header) gets it brought up to date where it is (the
|
|
491
|
+
hints may have been switched since the header was built, and it says
|
|
492
|
+
Ctrl-/ when `?` isn't free); otherwise it is added to the top line,
|
|
493
|
+
`out[0]`. `out[k]` is drawn on row first_row + k."""
|
|
494
|
+
wide = help_toggle_width()
|
|
495
|
+
now, now_w = help_corner_text(help_key)
|
|
496
|
+
for k, line in enumerate(out[:4]):
|
|
497
|
+
was = next((" " * (wide - w) + t for t, w in _toggle_variants() if " " * (wide - w) + t in line), None)
|
|
498
|
+
if was is None:
|
|
499
|
+
continue
|
|
500
|
+
line = out[k] = line.replace(was, " " * (wide - now_w) + now, 1)
|
|
501
|
+
plain = ui.strip_ansi(line)
|
|
502
|
+
at = plain.find(ui.strip_ansi(help_corner_text(help_key)[0]))
|
|
503
|
+
if at >= 0:
|
|
504
|
+
cells[(first_row + k, ui.visual_len(plain[:at]) + 2)] = HINTS_CLICK
|
|
505
|
+
if help_key:
|
|
506
|
+
cells['__help_key__'] = True
|
|
507
|
+
return
|
|
508
|
+
if out:
|
|
509
|
+
out[0] = add_help_corner(out[0], first_row, cells, help_key)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _hint(*pairs, extra="", always: bool = False) -> str:
|
|
513
|
+
"""
|
|
514
|
+
Lays out the hint bar, falling back through: one line → pyramid → grid →
|
|
515
|
+
aligned stack → split stack.
|
|
516
|
+
Every hint bar comes through here, so hiding them (hints_visible) is one
|
|
517
|
+
check; `always` draws regardless (the player's own `[i] help` stays in its bar).
|
|
518
|
+
"""
|
|
519
|
+
if (not pairs and not extra) or not (always or hints_visible()):
|
|
520
|
+
return ""
|
|
521
|
+
|
|
522
|
+
cols = _cols()
|
|
523
|
+
|
|
524
|
+
# Parse items into structured tuples: (key, value, raw_string_for_math).
|
|
525
|
+
# An action left without a key has nothing to show.
|
|
526
|
+
parsed_items = []
|
|
527
|
+
for k, v in pairs:
|
|
528
|
+
if isinstance(k, keys.HintKey) and not k:
|
|
529
|
+
continue
|
|
530
|
+
parsed_items.append((k, v, f"[{k}] {v}"))
|
|
531
|
+
|
|
532
|
+
if extra:
|
|
533
|
+
plain_extra = ui.strip_ansi(extra).strip()
|
|
534
|
+
if plain_extra:
|
|
535
|
+
m = re.match(r'\[(.*?)\]\s*(.*)', plain_extra)
|
|
536
|
+
if m:
|
|
537
|
+
parsed_items.append((m.group(1), m.group(2), f"[{m.group(1)}] {m.group(2)}"))
|
|
538
|
+
else:
|
|
539
|
+
parsed_items.append(("", plain_extra, plain_extra))
|
|
540
|
+
|
|
541
|
+
total_items = len(parsed_items)
|
|
542
|
+
|
|
543
|
+
def render_inline(k, v):
|
|
544
|
+
"""Render one [key] value pair inline, dimmed with a bold key."""
|
|
545
|
+
if not k: return f"{C.DIM}{v}{C.RESET}"
|
|
546
|
+
return f"{C.RESET}{C.DIM}[{C.RESET}{C.BOLD}{k}{C.RESET}{C.DIM}] {v}{C.RESET}"
|
|
547
|
+
|
|
548
|
+
# Interpunct (·): the one separator used everywhere: hint bars, player
|
|
549
|
+
# details, bulk headers, multi-value fields.
|
|
550
|
+
sep = f"{C.DIM} · {C.RESET}"
|
|
551
|
+
raw_sep_len = len(' · ')
|
|
552
|
+
|
|
553
|
+
# LAYOUT 1: Centred Long Line
|
|
554
|
+
raw_len = sum(len(raw) for _, _, raw in parsed_items) + raw_sep_len * (total_items - 1)
|
|
555
|
+
if raw_len <= cols:
|
|
556
|
+
line = sep.join(render_inline(k, v) for k, v, _ in parsed_items)
|
|
557
|
+
pad = max(0, cols - raw_len) // 2
|
|
558
|
+
return (" " * pad) + line
|
|
559
|
+
|
|
560
|
+
# LAYOUT 2: Upside-Down Pyramid
|
|
561
|
+
def get_pyramid_distribution(n):
|
|
562
|
+
"""Row sizes for an upside-down pyramid: start near sqrt(2n) and shrink
|
|
563
|
+
by one each row until all n items are placed."""
|
|
564
|
+
rows = []
|
|
565
|
+
current_row_size = math.ceil(math.sqrt(2 * n))
|
|
566
|
+
while n > 0:
|
|
567
|
+
take = min(current_row_size, n)
|
|
568
|
+
rows.append(take)
|
|
569
|
+
n -= take
|
|
570
|
+
current_row_size = max(1, current_row_size - 1)
|
|
571
|
+
return rows
|
|
572
|
+
|
|
573
|
+
pyr_dist = get_pyramid_distribution(total_items)
|
|
574
|
+
if len(pyr_dist) > 1 and pyr_dist[0] > pyr_dist[-1]:
|
|
575
|
+
fits = True
|
|
576
|
+
pyr_lines = []
|
|
577
|
+
idx = 0
|
|
578
|
+
for r in pyr_dist:
|
|
579
|
+
row_items = parsed_items[idx:idx+r]
|
|
580
|
+
r_raw_len = sum(len(raw) for _, _, raw in row_items) + raw_sep_len * (len(row_items) - 1)
|
|
581
|
+
|
|
582
|
+
if r_raw_len > cols:
|
|
583
|
+
fits = False
|
|
584
|
+
break
|
|
585
|
+
|
|
586
|
+
line = sep.join(render_inline(k, v) for k, v, _ in row_items)
|
|
587
|
+
pad = max(0, cols - r_raw_len) // 2
|
|
588
|
+
pyr_lines.append((" " * pad) + line)
|
|
589
|
+
idx += r
|
|
590
|
+
|
|
591
|
+
if fits:
|
|
592
|
+
return "\n".join(pyr_lines)
|
|
593
|
+
|
|
594
|
+
# LAYOUT 3: Grid (Side-by-side uniform columns)
|
|
595
|
+
max_item_len = max((len(raw) for _, _, raw in parsed_items), default=0)
|
|
596
|
+
gutter = 4
|
|
597
|
+
col_width = max_item_len + gutter
|
|
598
|
+
possible_cols = max(1, cols // col_width)
|
|
599
|
+
|
|
600
|
+
if possible_cols >= 2:
|
|
601
|
+
grid_lines = []
|
|
602
|
+
for i in range(0, total_items, possible_cols):
|
|
603
|
+
row = parsed_items[i:i+possible_cols]
|
|
604
|
+
raw_row_len = sum(col_width for _ in row) - gutter
|
|
605
|
+
formatted_parts = []
|
|
606
|
+
for k, v, raw in row:
|
|
607
|
+
space_padding = " " * (col_width - len(raw))
|
|
608
|
+
formatted_parts.append(render_inline(k, v) + space_padding)
|
|
609
|
+
|
|
610
|
+
row_str = "".join(formatted_parts).rstrip()
|
|
611
|
+
pad = max(0, cols - raw_row_len) // 2
|
|
612
|
+
grid_lines.append((" " * pad) + row_str)
|
|
613
|
+
return "\n".join(grid_lines)
|
|
614
|
+
|
|
615
|
+
# LAYOUT 4: Aligned Vertical Stack
|
|
616
|
+
# Center aligned: Keys right-aligned to spine, values left-aligned from spine
|
|
617
|
+
max_k_len = max((len(f"[{k}]") for k, _, _ in parsed_items if k), default=0)
|
|
618
|
+
max_v_len = max((len(v) for _, v, _ in parsed_items), default=0)
|
|
619
|
+
total_stack_w = max_k_len + 1 + max_v_len # key + space + value
|
|
620
|
+
|
|
621
|
+
if total_stack_w <= cols:
|
|
622
|
+
stack_lines = []
|
|
623
|
+
global_pad = max(0, cols - total_stack_w) // 2
|
|
624
|
+
|
|
625
|
+
for k, v, _ in parsed_items:
|
|
626
|
+
if k:
|
|
627
|
+
k_raw = f"[{k}]"
|
|
628
|
+
k_space_pad = " " * (max_k_len - len(k_raw))
|
|
629
|
+
left_side = f"{k_space_pad}{C.RESET}{C.DIM}[{C.RESET}{C.BOLD}{k}{C.RESET}{C.DIM}]{C.RESET}"
|
|
630
|
+
else:
|
|
631
|
+
left_side = " " * max_k_len
|
|
632
|
+
|
|
633
|
+
right_side = f"{C.DIM}{v}{C.RESET}"
|
|
634
|
+
stack_lines.append(f"{' ' * global_pad}{left_side} {right_side}")
|
|
635
|
+
return "\n".join(stack_lines)
|
|
636
|
+
|
|
637
|
+
# LAYOUT 5: Split Vertical Stack (narrowest fallback)
|
|
638
|
+
# Key on row 1, value on row 2, dot separator between pairs.
|
|
639
|
+
split_lines = []
|
|
640
|
+
for i, (k, v, _) in enumerate(parsed_items):
|
|
641
|
+
if k:
|
|
642
|
+
k_raw = f"[{k}]"
|
|
643
|
+
k_pad = max(0, cols - len(k_raw)) // 2
|
|
644
|
+
split_lines.append(f"{' ' * k_pad}{C.RESET}{C.DIM}[{C.RESET}{C.BOLD}{k}{C.RESET}{C.DIM}]{C.RESET}")
|
|
645
|
+
|
|
646
|
+
v_pad = max(0, cols - len(v)) // 2
|
|
647
|
+
split_lines.append(f"{' ' * v_pad}{C.DIM}{v}{C.RESET}")
|
|
648
|
+
|
|
649
|
+
# Add centered separator dot between discrete blocks
|
|
650
|
+
if i < total_items - 1:
|
|
651
|
+
dot_pad = max(0, cols - 1) // 2
|
|
652
|
+
split_lines.append(f"{' ' * dot_pad}{C.DIM}⋅{C.RESET}")
|
|
653
|
+
|
|
654
|
+
return "\n".join(split_lines)
|
|
655
|
+
|
|
656
|
+
# --- Clickable hints & now-playing box hit-testing -------------------------
|
|
657
|
+
# Hint keys render as ``[key] label`` with only ``key`` bold/bright; a click is
|
|
658
|
+
# actionable only when it lands on those bright glyphs. Multi-key labels split
|
|
659
|
+
# into separate buttons: a '/' between keys is a non-clickable separator, and an
|
|
660
|
+
# adjacent arrow cluster (``↑↓``, ``←→``) is one button per arrow. Each button
|
|
661
|
+
# maps to the SAME synthesised key the keyboard produces, so the widgets need no
|
|
662
|
+
# extra per-key logic: a click just replays that key through their normal switch.
|
|
663
|
+
|
|
664
|
+
_HINT_ARROWS = {'↑': 'UP', '↓': 'DOWN', '←': 'LEFT', '→': 'RIGHT', '⇞': 'PGUP', '⇟': 'PGDN'}
|
|
665
|
+
_HINT_WORDS = {
|
|
666
|
+
'space': 'SPACE', 'spc': 'SPACE', 'esc': 'ESC', 'tab': 'TAB', '↵': 'ENTER',
|
|
667
|
+
'pgup': 'PGUP', 'pgdn': 'PGDN', '⇧tab': 'BACKTAB', 'home': 'HOME', 'end': 'END',
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _hint_key_tokens(key: str) -> list[tuple[int, int, str]]:
|
|
672
|
+
"""Split a hint key label into clickable ``(offset, glyph_len, synth_key)``
|
|
673
|
+
buttons. ``offset`` is 0-based within the key text; the '/' joiners it skips
|
|
674
|
+
over are left non-clickable."""
|
|
675
|
+
if isinstance(key, keys.HintKey): # built from the keymap: it knows
|
|
676
|
+
return list(key.tokens) # the real key behind each glyph
|
|
677
|
+
segs = key.split('/') if (key and key != '/' and '/' in key) else [key]
|
|
678
|
+
tokens: list[tuple[int, int, str]] = []
|
|
679
|
+
off = 0
|
|
680
|
+
for si, seg in enumerate(segs):
|
|
681
|
+
if si > 0:
|
|
682
|
+
off += 1 # the '/' separator column (not clickable)
|
|
683
|
+
if not seg:
|
|
684
|
+
continue
|
|
685
|
+
if all(c in _HINT_ARROWS for c in seg): # e.g. "↑↓" → one button per arrow
|
|
686
|
+
for c in seg:
|
|
687
|
+
tokens.append((off, 1, _HINT_ARROWS[c]))
|
|
688
|
+
off += 1
|
|
689
|
+
elif seg.lower() in _HINT_WORDS: # "space"/"esc"/"tab"/"↵"/"PgUp"…
|
|
690
|
+
tokens.append((off, len(seg), _HINT_WORDS[seg.lower()]))
|
|
691
|
+
off += len(seg)
|
|
692
|
+
elif len(seg) == 2 and seg[0] == '^': # "^N" → Ctrl-N control char
|
|
693
|
+
tokens.append((off, 2, chr(ord(seg[1].upper()) - 64)))
|
|
694
|
+
off += 2
|
|
695
|
+
else: # a single glyph / plain letter
|
|
696
|
+
tokens.append((off, len(seg), _HINT_WORDS.get(seg.lower(), seg)))
|
|
697
|
+
off += len(seg)
|
|
698
|
+
return tokens
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def add_hint_click_cells_auto(cells: dict, line: str, base_row: int,
|
|
702
|
+
left_inset: int = 0) -> None:
|
|
703
|
+
"""Like add_hint_click_cells but auto-detects ``[key]`` groups in the plain
|
|
704
|
+
text (no pairs needed). Use only on lines known to be a hint bar; arbitrary
|
|
705
|
+
bracketed text (e.g. a lyric ``[Chorus]``) would be picked up as a key."""
|
|
706
|
+
plain = ui.display_text(line)
|
|
707
|
+
for m in re.finditer(r'\[([^\[\]]+)\]', plain):
|
|
708
|
+
key_col0 = m.start() + 1
|
|
709
|
+
for off, glen, synth in _hint_key_tokens(m.group(1)):
|
|
710
|
+
for c in range(glen):
|
|
711
|
+
cells[(base_row, left_inset + key_col0 + off + c + 1)] = synth
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def add_hint_click_cells(cells: dict, line: str, base_row: int, pairs,
|
|
715
|
+
left_inset: int = 0) -> None:
|
|
716
|
+
"""Populate ``cells`` (a ``{(row, col): synth_key}`` map) with the clickable
|
|
717
|
+
bright-key glyphs found on one rendered hint ``line`` at absolute ``base_row``.
|
|
718
|
+
``pairs`` is the (key, label) sequence that produced the hint bar."""
|
|
719
|
+
plain = ui.display_text(line)
|
|
720
|
+
for k, _v in pairs:
|
|
721
|
+
if not k:
|
|
722
|
+
continue
|
|
723
|
+
idx = plain.find(f"[{k}]")
|
|
724
|
+
if idx < 0:
|
|
725
|
+
continue
|
|
726
|
+
key_col0 = idx + 1 # 0-based index of key[0] (just past '[')
|
|
727
|
+
for off, glen, synth in _hint_key_tokens(k):
|
|
728
|
+
for c in range(glen):
|
|
729
|
+
col = left_inset + key_col0 + off + c + 1 # 1-based screen column
|
|
730
|
+
cells[(base_row, col)] = synth
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _hint_pin_target() -> int:
|
|
734
|
+
"""The flowed-line count after which a widget's hint bar sits pinned at the
|
|
735
|
+
bottom, directly above the now-playing box and status bar, so its keys keep the
|
|
736
|
+
same screen position across redraws (repeated clicks don't chase the bar)."""
|
|
737
|
+
rows = ui.get_terminal_height()
|
|
738
|
+
return rows - 1 - ui.MARGIN_V - max(ui.footer_height(), ui.MARGIN_V)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# Now-playing box transport-icon columns, derived from the one place the glyph
|
|
742
|
+
# layout is defined (ui.FOOTER_GLYPH_COLS) rather than restated here: the
|
|
743
|
+
# box is inset by MARGIN_H, then "│ " precedes the content, so a glyph at content
|
|
744
|
+
# offset `o` lands on 1-based column MARGIN_H + 3 + o. Each glyph claims its own
|
|
745
|
+
# column plus the space after it, so a click just to the right still lands.
|
|
746
|
+
def _footer_glyph_cols() -> list[tuple[str, int, int]]:
|
|
747
|
+
"""(action, first_col, last_col) for each transport glyph in the box."""
|
|
748
|
+
base = ui.MARGIN_H + 3
|
|
749
|
+
actions = ('playpause', 'next')
|
|
750
|
+
# A glyph claims its own cells plus the space after it, so a click just to
|
|
751
|
+
# the right of a narrow glyph still lands on it.
|
|
752
|
+
return [(a, base + start, base + start + width)
|
|
753
|
+
for a, (start, width) in zip(actions, ui.FOOTER_GLYPH_COLS)]
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def footer_click_action(row: int, col: int) -> str | None:
|
|
757
|
+
"""Classify a click against the now-playing box: ``'prev'`` / ``'playpause'``
|
|
758
|
+
/ ``'next'`` on the transport glyphs, ``'open'`` anywhere else in the box, or
|
|
759
|
+
``None`` when the click misses it (or no box is shown)."""
|
|
760
|
+
h = _footer_prev_h[0]
|
|
761
|
+
if h <= 0 or not ui.footer_active():
|
|
762
|
+
return None
|
|
763
|
+
rows = ui.get_terminal_height()
|
|
764
|
+
top = rows - h
|
|
765
|
+
if not (top <= row <= rows - 1):
|
|
766
|
+
return None
|
|
767
|
+
if row == top + 1: # the content row that carries the icons
|
|
768
|
+
for action, lo, hi in _footer_glyph_cols():
|
|
769
|
+
if lo <= col <= hi:
|
|
770
|
+
return action
|
|
771
|
+
return 'open'
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _render_status_bar():
|
|
775
|
+
"""Redraw the bottom status bar in place, saving/restoring the cursor so
|
|
776
|
+
the text input caret doesn't move."""
|
|
777
|
+
rows = ui.get_terminal_height()
|
|
778
|
+
if rows <= 0:
|
|
779
|
+
return
|
|
780
|
+
status = ui.get_status_line()
|
|
781
|
+
# \0337 / \0338 (via save_cursor) keep the caret where the text input left
|
|
782
|
+
# it rather than jumping to the status row; an unchanged bar writes nothing.
|
|
783
|
+
screen_paint({rows: status}, save_cursor=True)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
class Choice:
|
|
787
|
+
__slots__ = ('title', 'value', 'checked', 'disabled', 'cells', 'cursor_title')
|
|
788
|
+
|
|
789
|
+
def __init__(self, title: str, value: object = None, checked: bool = False,
|
|
790
|
+
disabled: bool = False, cells: list | None = None,
|
|
791
|
+
cursor_title: str | None = None) -> None:
|
|
792
|
+
"""Build a selectable/checkable row for select(), defaulting value to title."""
|
|
793
|
+
self.title = title
|
|
794
|
+
self.value = value if value is not None else title
|
|
795
|
+
self.checked = checked
|
|
796
|
+
self.disabled = disabled # non-selectable separator / section heading
|
|
797
|
+
self.cells = cells # structured column data for columns= mode
|
|
798
|
+
self.cursor_title = cursor_title # alternate label shown when cursor is on this row
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
# The single inter-column gap for every list in the app, so columns line up the
|
|
802
|
+
# same way in every list. Narrow terminals are handled by column `priority` (columns drop)
|
|
803
|
+
# and by the dynamically computed pin gap, not by varying this.
|
|
804
|
+
COL_GAP = 3
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
class Column:
|
|
808
|
+
"""A column spec for a structured select() table (no string parsing).
|
|
809
|
+
|
|
810
|
+
style : 'primary' | 'static-dim' | 'dynamic-dim' | 'accent' | 'normal'
|
|
811
|
+
align : 'left' | 'right'
|
|
812
|
+
flex : absorbs leftover width, truncates (use for the title column)
|
|
813
|
+
pin : laid against the right edge (e.g. duration)
|
|
814
|
+
max_frac : clamp column to this fraction of total width (0.0-1.0)
|
|
815
|
+
gap : leading gap before this column (defaults to `COL_GAP`, the one
|
|
816
|
+
value every list uses; the pin block's separation from the left
|
|
817
|
+
block is computed per render, so this is only the minimum)
|
|
818
|
+
priority : drop-order when the row is too narrow to show every column
|
|
819
|
+
readably. None (default) = essential, never dropped. A number
|
|
820
|
+
marks the column droppable; the lowest-priority droppable column
|
|
821
|
+
is dropped first, so give the least important columns the lowest
|
|
822
|
+
numbers (e.g. 1 = first to go).
|
|
823
|
+
"""
|
|
824
|
+
__slots__ = ('style', 'align', 'flex', 'pin', 'min_width', 'max_width', 'max_frac',
|
|
825
|
+
'gap', 'priority')
|
|
826
|
+
|
|
827
|
+
def __init__(self, style: str = 'normal', align: str = 'left', flex: bool = False,
|
|
828
|
+
pin: bool = False, min_width: int = 0, max_width: int | None = None,
|
|
829
|
+
max_frac: float | None = None, gap: int = COL_GAP,
|
|
830
|
+
priority: float | None = None) -> None:
|
|
831
|
+
"""Build a column spec for a structured select() table."""
|
|
832
|
+
self.style = style
|
|
833
|
+
self.align = align
|
|
834
|
+
self.flex = flex
|
|
835
|
+
self.pin = pin
|
|
836
|
+
self.min_width = min_width
|
|
837
|
+
self.max_width = max_width
|
|
838
|
+
self.max_frac = max_frac
|
|
839
|
+
self.gap = gap
|
|
840
|
+
self.priority = priority
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _cell_text(cell) -> tuple[str, str | None]:
|
|
844
|
+
"""Return (plain_text, style_override) for a str, (str, style) tuple, or list of segments."""
|
|
845
|
+
if isinstance(cell, list):
|
|
846
|
+
return "".join(str(seg[0]) if isinstance(seg, tuple) else str(seg) for seg in cell), None
|
|
847
|
+
if isinstance(cell, tuple):
|
|
848
|
+
return str(cell[0]), (cell[1] if len(cell) > 1 else None)
|
|
849
|
+
return str(cell), None
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _style_cell(text: str, style: str, is_current: bool) -> str:
|
|
853
|
+
"""Apply a named cell style (dim/dynamic-dim/accent/primary/normal) to text."""
|
|
854
|
+
if not text:
|
|
855
|
+
return ""
|
|
856
|
+
if style in ('dim', 'static-dim'):
|
|
857
|
+
return f"{C.DIM}{text}{C.RESET}"
|
|
858
|
+
if style == 'dynamic-dim':
|
|
859
|
+
return f"{C.BOLD}{C.DIM}{text}{C.RESET}" if is_current else f"{C.DIM}{text}{C.RESET}"
|
|
860
|
+
if style == 'accent':
|
|
861
|
+
return f"{C.ACCENT}{text}{C.RESET}"
|
|
862
|
+
if style == 'primary':
|
|
863
|
+
return f"{C.BOLD}{text}{C.RESET}" if is_current else text
|
|
864
|
+
if style == 'cursor':
|
|
865
|
+
# The block cursor as a cell segment; see block_cursor(), which does the
|
|
866
|
+
# same thing where a whole line rather than a table cell is being drawn.
|
|
867
|
+
return f"{C.INVERT}{C.BOLD}{text}{C.RESET}"
|
|
868
|
+
return text # 'normal'
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _render_cell_segments(cell, style: str, is_current: bool, width: int, align: str,
|
|
872
|
+
force_dim: bool = False) -> str:
|
|
873
|
+
"""Render a cell (plain text or list of styled segments), truncated/padded
|
|
874
|
+
to `width` and aligned."""
|
|
875
|
+
if isinstance(cell, list):
|
|
876
|
+
parts = []
|
|
877
|
+
raw_len = 0
|
|
878
|
+
remaining = width
|
|
879
|
+
for seg in cell:
|
|
880
|
+
t, s = (str(seg[0]), seg[1]) if isinstance(seg, tuple) else (str(seg), style)
|
|
881
|
+
if force_dim:
|
|
882
|
+
s = 'dynamic-dim'
|
|
883
|
+
if remaining <= 0:
|
|
884
|
+
t = ""
|
|
885
|
+
else:
|
|
886
|
+
t = ui.truncate_text(t, remaining)
|
|
887
|
+
seg_w = ui.visual_len(t)
|
|
888
|
+
raw_len += seg_w
|
|
889
|
+
remaining -= seg_w
|
|
890
|
+
parts.append(_style_cell(t, s, is_current))
|
|
891
|
+
text = "".join(parts)
|
|
892
|
+
pad = " " * max(0, width - raw_len)
|
|
893
|
+
else:
|
|
894
|
+
raw_text, override = _cell_text(cell)
|
|
895
|
+
raw_text = ui.truncate_text(raw_text, width)
|
|
896
|
+
raw_len = ui.visual_len(raw_text)
|
|
897
|
+
text = _style_cell(raw_text, override or style, is_current)
|
|
898
|
+
pad = " " * max(0, width - raw_len)
|
|
899
|
+
return (pad + text) if align == 'right' else (text + pad)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _table_widths(rows_cells: list, columns: list, eff: int,
|
|
903
|
+
pointer_w: int, right_margin: int,
|
|
904
|
+
visible_cells: list | None = None) -> list[int]:
|
|
905
|
+
"""Compute per-column widths that fit the effective width `eff`.
|
|
906
|
+
|
|
907
|
+
Content sets each column's natural width, scanned across *all* rows, so a
|
|
908
|
+
wide entry far down the list is accounted for and the layout stays stable
|
|
909
|
+
while scrolling. Natural widths are clamped by max_frac / max_width and
|
|
910
|
+
floored by min_width. Then space is reconciled with the terminal:
|
|
911
|
+
|
|
912
|
+
* blank → a droppable column with nothing to show in the *visible* window
|
|
913
|
+
is dropped outright. Its natural width comes from all rows, so
|
|
914
|
+
an off-screen entry would otherwise reserve a wide column that
|
|
915
|
+
renders as empty space on every row you can actually see (a
|
|
916
|
+
genre far down the search results, a featured artist nobody in
|
|
917
|
+
view has). Width it can't use is width the title column needs;
|
|
918
|
+
* drop → if not every column can fit even at its comfortable minimum,
|
|
919
|
+
drop the lowest-priority droppable column (Column.priority) and
|
|
920
|
+
retry; essential columns (priority=None) are never dropped;
|
|
921
|
+
* fits → flex column(s) share the leftover evenly (each respecting its
|
|
922
|
+
own max_frac / max_width cap **and its own content**) so pinned
|
|
923
|
+
columns sit flush right. A flex column never grows past what it
|
|
924
|
+
has to show: padding a left column out to the full width just
|
|
925
|
+
buries the row's right-hand block behind a field of blanks. Any
|
|
926
|
+
surplus is left unallocated and the renderer spends it as the
|
|
927
|
+
single gap between the left block and the pinned block;
|
|
928
|
+
* tight → the widest kept column gives up space first, one unit at a time,
|
|
929
|
+
never below a readable floor, so narrow columns (durations,
|
|
930
|
+
counts) stay intact and only the widest columns truncate.
|
|
931
|
+
|
|
932
|
+
`visible_cells` is the window of rows actually on screen (defaults to
|
|
933
|
+
`rows_cells`); only the blank pass uses it, so widths stay scroll-stable.
|
|
934
|
+
|
|
935
|
+
Returns a width per column; a dropped column's width is -1 (skipped by the
|
|
936
|
+
renderer, which also drops its gap).
|
|
937
|
+
"""
|
|
938
|
+
ncol = len(columns)
|
|
939
|
+
content = [0] * ncol
|
|
940
|
+
for cells in rows_cells:
|
|
941
|
+
for i in range(min(ncol, len(cells))):
|
|
942
|
+
content[i] = max(content[i], ui.visual_len(_cell_text(cells[i])[0]))
|
|
943
|
+
|
|
944
|
+
# What each column actually has to show in the window on screen.
|
|
945
|
+
shown = [0] * ncol
|
|
946
|
+
for cells in (rows_cells if visible_cells is None else visible_cells):
|
|
947
|
+
for i in range(min(ncol, len(cells))):
|
|
948
|
+
shown[i] = max(shown[i], ui.visual_len(_cell_text(cells[i])[0]))
|
|
949
|
+
|
|
950
|
+
def _cap(col) -> int | None:
|
|
951
|
+
"""The hard upper bound a column may reach (max_frac / max_width), or None."""
|
|
952
|
+
cap = int(eff * col.max_frac) if col.max_frac is not None else None
|
|
953
|
+
if col.max_width is not None:
|
|
954
|
+
cap = col.max_width if cap is None else min(cap, col.max_width)
|
|
955
|
+
return cap
|
|
956
|
+
|
|
957
|
+
# Natural (capped, min-floored) width each column would like.
|
|
958
|
+
natural = [0] * ncol
|
|
959
|
+
for i, col in enumerate(columns):
|
|
960
|
+
w = content[i]
|
|
961
|
+
cap = _cap(col)
|
|
962
|
+
if cap is not None:
|
|
963
|
+
w = min(w, cap)
|
|
964
|
+
natural[i] = max(w, col.min_width)
|
|
965
|
+
|
|
966
|
+
def _comfort(i: int) -> int:
|
|
967
|
+
"""Smallest width column i still reads at (its content, if that's smaller)."""
|
|
968
|
+
return max(columns[i].min_width, min(natural[i], _MIN_COL_FLOOR))
|
|
969
|
+
|
|
970
|
+
def _overhead(ks: list) -> int:
|
|
971
|
+
"""Fixed, non-content width for a kept set: pointer, gaps, margin, pin gap."""
|
|
972
|
+
pin = any(columns[i].pin for i in ks)
|
|
973
|
+
return (pointer_w + right_margin + sum(columns[i].gap for i in ks)
|
|
974
|
+
+ (_MIN_PIN_GAP if pin else 0))
|
|
975
|
+
|
|
976
|
+
kept = list(range(ncol))
|
|
977
|
+
|
|
978
|
+
# Blank pass: a droppable column with nothing to show in the visible window
|
|
979
|
+
# reserves width that renders as empty space on every row on screen. Drop it
|
|
980
|
+
# and give the space to the columns that do have something to say; it comes
|
|
981
|
+
# back when you scroll to rows that fill it. Never drops the last column.
|
|
982
|
+
blank = [i for i in kept
|
|
983
|
+
if columns[i].priority is not None and shown[i] == 0 and columns[i].min_width == 0]
|
|
984
|
+
if len(blank) < len(kept):
|
|
985
|
+
for i in blank:
|
|
986
|
+
kept.remove(i)
|
|
987
|
+
|
|
988
|
+
# Drop pass: while even everyone's comfortable minimum can't fit, shed the
|
|
989
|
+
# lowest-priority droppable column (ties: the rightmost goes first).
|
|
990
|
+
while len(kept) > 1 and _overhead(kept) + sum(_comfort(i) for i in kept) > eff:
|
|
991
|
+
droppable = [i for i in kept if columns[i].priority is not None]
|
|
992
|
+
if not droppable:
|
|
993
|
+
break
|
|
994
|
+
victim = min(droppable, key=lambda i: (columns[i].priority, -i))
|
|
995
|
+
kept.remove(victim)
|
|
996
|
+
|
|
997
|
+
widths = [-1] * ncol # -1 = dropped (renderer skips it and its gap)
|
|
998
|
+
for i in kept:
|
|
999
|
+
widths[i] = natural[i]
|
|
1000
|
+
|
|
1001
|
+
flex_idxs = [i for i in kept if columns[i].flex]
|
|
1002
|
+
has_pin = any(columns[i].pin for i in kept)
|
|
1003
|
+
gaps = sum(columns[i].gap for i in kept)
|
|
1004
|
+
budget = eff - pointer_w - gaps - right_margin
|
|
1005
|
+
if has_pin:
|
|
1006
|
+
budget -= _MIN_PIN_GAP # reserve the left/right inter-block gap
|
|
1007
|
+
budget = max(0, budget)
|
|
1008
|
+
|
|
1009
|
+
total = sum(widths[i] for i in kept)
|
|
1010
|
+
if total < budget and flex_idxs:
|
|
1011
|
+
# Surplus: round-robin one unit at a time into the flex columns, each
|
|
1012
|
+
# stopping at its own cap *or its own content*, whichever comes first:
|
|
1013
|
+
# growing a column past what it has to show only pads it with blanks and
|
|
1014
|
+
# pushes the pinned block away from the text it belongs to. Leftover is
|
|
1015
|
+
# deliberately unspent: _render_table_row turns it into the one gap
|
|
1016
|
+
# between the left block and the right-pinned block.
|
|
1017
|
+
surplus = budget - total
|
|
1018
|
+
caps = {}
|
|
1019
|
+
for i in flex_idxs:
|
|
1020
|
+
cap_i = _cap(columns[i])
|
|
1021
|
+
need_i = max(content[i], columns[i].min_width)
|
|
1022
|
+
caps[i] = need_i if cap_i is None else min(cap_i, need_i)
|
|
1023
|
+
progressed = True
|
|
1024
|
+
while surplus > 0 and progressed:
|
|
1025
|
+
progressed = False
|
|
1026
|
+
for i in flex_idxs:
|
|
1027
|
+
if surplus == 0:
|
|
1028
|
+
break
|
|
1029
|
+
cap_i = caps[i]
|
|
1030
|
+
if cap_i is None or widths[i] < cap_i:
|
|
1031
|
+
widths[i] += 1
|
|
1032
|
+
surplus -= 1
|
|
1033
|
+
progressed = True
|
|
1034
|
+
elif total > budget:
|
|
1035
|
+
# Over budget: shave the widest kept column repeatedly until it fits,
|
|
1036
|
+
# never below its floor (min_width, a readable minimum, or its own
|
|
1037
|
+
# content if that is already smaller). The readable minimum eases toward
|
|
1038
|
+
# the fair per-column share when a many-column row is genuinely cramped,
|
|
1039
|
+
# so the layout still fits. n and the deficit are both small.
|
|
1040
|
+
floor_cap = min(_MIN_COL_FLOOR, max(1, budget // len(kept)))
|
|
1041
|
+
floors = {i: min(widths[i], max(columns[i].min_width, floor_cap)) for i in kept}
|
|
1042
|
+
deficit = total - budget
|
|
1043
|
+
while deficit > 0:
|
|
1044
|
+
widest = -1
|
|
1045
|
+
for i in kept:
|
|
1046
|
+
if widths[i] > floors[i] and (widest < 0 or widths[i] > widths[widest]):
|
|
1047
|
+
widest = i
|
|
1048
|
+
if widest < 0:
|
|
1049
|
+
break # everything at its floor; clip guard handles the rest
|
|
1050
|
+
widths[widest] -= 1
|
|
1051
|
+
deficit -= 1
|
|
1052
|
+
return widths
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _render_table_row(cells: list, columns: list, is_current: bool,
|
|
1056
|
+
widths: list[int], eff: int, right_margin: int,
|
|
1057
|
+
is_checked: bool | None = None,
|
|
1058
|
+
disabled: bool = False, dim: bool = False) -> str:
|
|
1059
|
+
"""Render one table row, laying out left-aligned and right-pinned columns
|
|
1060
|
+
and applying pointer/check/disabled styling.
|
|
1061
|
+
|
|
1062
|
+
`dim` greys a row that is selectable but not in focus, used by the sectioned
|
|
1063
|
+
search to quiet every section except the one the cursor is in. Unlike
|
|
1064
|
+
`disabled` it keeps the normal row prefix, so columns stay aligned with the
|
|
1065
|
+
focused section above and below it.
|
|
1066
|
+
"""
|
|
1067
|
+
if disabled:
|
|
1068
|
+
# Match enabled non-current prefix exactly so columns stay aligned.
|
|
1069
|
+
if is_checked is not None:
|
|
1070
|
+
left = f" {C.DIM}•{C.RESET}" # 4 spaces + dim bullet = same as " •"
|
|
1071
|
+
else:
|
|
1072
|
+
left = " " # 3 spaces, same as single-select non-current
|
|
1073
|
+
for i, col in enumerate(columns):
|
|
1074
|
+
if not col.pin and widths[i] >= 0:
|
|
1075
|
+
left += " " * col.gap + _render_cell_segments(
|
|
1076
|
+
cells[i] if i < len(cells) else "", 'dynamic-dim', False, widths[i], col.align,
|
|
1077
|
+
force_dim=True)
|
|
1078
|
+
right = ""
|
|
1079
|
+
for i, col in enumerate(columns):
|
|
1080
|
+
if col.pin and widths[i] >= 0:
|
|
1081
|
+
right += " " * col.gap + _render_cell_segments(
|
|
1082
|
+
cells[i] if i < len(cells) else "", 'dynamic-dim', False, widths[i], col.align,
|
|
1083
|
+
force_dim=True)
|
|
1084
|
+
if right:
|
|
1085
|
+
gap = max(2, (eff - right_margin) - ui.visual_len(left) - ui.visual_len(right))
|
|
1086
|
+
return left + " " * gap + right + " " * right_margin
|
|
1087
|
+
return left
|
|
1088
|
+
|
|
1089
|
+
pointer = f"{C.ACCENT}›{C.RESET}" if is_current else " "
|
|
1090
|
+
if is_checked is None:
|
|
1091
|
+
left = f" {pointer}"
|
|
1092
|
+
else:
|
|
1093
|
+
glyph = f"{C.GREEN}✔{C.RESET}" if is_checked else f"{C.DIM}•{C.RESET}"
|
|
1094
|
+
left = f" {pointer} {glyph}"
|
|
1095
|
+
for i, col in enumerate(columns):
|
|
1096
|
+
if not col.pin and widths[i] >= 0:
|
|
1097
|
+
left += " " * col.gap + _render_cell_segments(
|
|
1098
|
+
cells[i] if i < len(cells) else "", col.style, is_current, widths[i], col.align,
|
|
1099
|
+
force_dim=dim)
|
|
1100
|
+
|
|
1101
|
+
right = ""
|
|
1102
|
+
for i, col in enumerate(columns):
|
|
1103
|
+
if col.pin and widths[i] >= 0:
|
|
1104
|
+
right += " " * col.gap + _render_cell_segments(
|
|
1105
|
+
cells[i] if i < len(cells) else "", col.style, is_current, widths[i], col.align,
|
|
1106
|
+
force_dim=dim)
|
|
1107
|
+
|
|
1108
|
+
if right:
|
|
1109
|
+
gap = max(2, (eff - right_margin) - ui.visual_len(left) - ui.visual_len(right))
|
|
1110
|
+
return left + " " * gap + right + " " * right_margin
|
|
1111
|
+
return left
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def edit_line(buf: list, pos: int, key: str) -> int | None:
|
|
1115
|
+
"""Apply one line-editing key to `buf` (a list of characters) in place
|
|
1116
|
+
(typing, space, backspace, delete, ←/→, Home/End) and return the caret's new
|
|
1117
|
+
position, or None when `key` isn't one of them. The one editor every text
|
|
1118
|
+
field uses, so a key works the same in each."""
|
|
1119
|
+
if key == 'BACKSPACE':
|
|
1120
|
+
if pos > 0:
|
|
1121
|
+
del buf[pos - 1]; pos -= 1
|
|
1122
|
+
elif key == 'DELETE':
|
|
1123
|
+
if pos < len(buf):
|
|
1124
|
+
del buf[pos]
|
|
1125
|
+
elif key == 'LEFT':
|
|
1126
|
+
pos = max(0, pos - 1)
|
|
1127
|
+
elif key == 'RIGHT':
|
|
1128
|
+
pos = min(len(buf), pos + 1)
|
|
1129
|
+
elif key == 'HOME':
|
|
1130
|
+
pos = 0
|
|
1131
|
+
elif key == 'END':
|
|
1132
|
+
pos = len(buf)
|
|
1133
|
+
elif key == 'SPACE' or (len(key) == 1 and key.isprintable()):
|
|
1134
|
+
buf.insert(pos, ' ' if key == 'SPACE' else key); pos += 1
|
|
1135
|
+
else:
|
|
1136
|
+
return None
|
|
1137
|
+
return pos
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def block_cursor(text: str, pos: int, base: str = '') -> str:
|
|
1141
|
+
"""`text` with a white block cursor sitting *on* the character at `pos`.
|
|
1142
|
+
|
|
1143
|
+
Reverse video on the character itself, never a bar drawn between two of
|
|
1144
|
+
them: a drawn bar occupies a column of its own, so every keystroke and every
|
|
1145
|
+
arrow press shifts the rest of the line sideways under the reader's eye.
|
|
1146
|
+
Past the end of the text the block sits on a space: the one place it does
|
|
1147
|
+
add a column, and there is nothing to its right to shift.
|
|
1148
|
+
|
|
1149
|
+
`base` is re-asserted after the block so a caller's row styling survives the
|
|
1150
|
+
RESET that closes it.
|
|
1151
|
+
"""
|
|
1152
|
+
close = f"{C.RESET}{base}"
|
|
1153
|
+
if pos >= len(text):
|
|
1154
|
+
return f"{text}{C.BACK}█{close}"
|
|
1155
|
+
return f"{text[:pos]}{C.INVERT}{C.BOLD}{text[pos]}{close}{text[pos + 1:]}"
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def block_cursor_width(text: str, pos: int) -> int:
|
|
1159
|
+
"""Columns `block_cursor` will occupy: one more than the text at its end."""
|
|
1160
|
+
return len(text) + (1 if pos >= len(text) else 0)
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def separator(title: str = "") -> Choice:
|
|
1164
|
+
"""A non-selectable heading/divider row for grouping a select() list."""
|
|
1165
|
+
return Choice(title, value=None, disabled=True)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _clip_ansi(s: str, width: int) -> str:
|
|
1170
|
+
"""Truncate a string to `width` visible columns, preserving ANSI escape
|
|
1171
|
+
sequences (they don't count toward width). Guarantees the line never wraps."""
|
|
1172
|
+
return ui.clip_ansi(s, width)
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _norm(choices: list) -> list:
|
|
1177
|
+
"""Normalize a mixed list of Choice/str/dict/choice-like objects into Choice instances."""
|
|
1178
|
+
out = []
|
|
1179
|
+
for c in choices:
|
|
1180
|
+
if isinstance(c, Choice):
|
|
1181
|
+
out.append(c)
|
|
1182
|
+
elif isinstance(c, str):
|
|
1183
|
+
out.append(Choice(c, c))
|
|
1184
|
+
elif isinstance(c, dict):
|
|
1185
|
+
out.append(Choice(
|
|
1186
|
+
title = c.get('name', c.get('title', str(c))),
|
|
1187
|
+
value = c.get('value', c.get('name', str(c))),
|
|
1188
|
+
checked = c.get('checked', False),
|
|
1189
|
+
disabled = c.get('disabled', False),
|
|
1190
|
+
))
|
|
1191
|
+
elif hasattr(c, 'title') and hasattr(c, 'value'):
|
|
1192
|
+
out.append(Choice(c.title, c.value, getattr(c, 'checked', False),
|
|
1193
|
+
getattr(c, 'disabled', False)))
|
|
1194
|
+
else:
|
|
1195
|
+
s = str(c)
|
|
1196
|
+
out.append(Choice(s, s))
|
|
1197
|
+
return out
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _read_key(fd: int) -> str:
|
|
1201
|
+
"""Read one key, discarding focus-out events. Focus-in is passed on: the
|
|
1202
|
+
terminal may not have painted us while unfocused, so every widget repaints
|
|
1203
|
+
on it (consume_chrome answers it with a full redraw)."""
|
|
1204
|
+
while True:
|
|
1205
|
+
key = _read_key_raw(fd)
|
|
1206
|
+
if key != 'FOCUS_OUT':
|
|
1207
|
+
return key
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
# How long to wait for the rest of an escape sequence before deciding the Esc
|
|
1211
|
+
# was pressed on its own. A real sequence's bytes arrive in the same burst, so
|
|
1212
|
+
# this only ever elapses for a genuine bare Esc; small enough that Esc still
|
|
1213
|
+
# feels instant, large enough to survive a slow link.
|
|
1214
|
+
_ESC_SEQ_TIMEOUT = 0.05
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def _byte_ready(fd: int, timeout: float) -> bool:
|
|
1218
|
+
"""True if another byte can be read from `fd` within `timeout` seconds."""
|
|
1219
|
+
try:
|
|
1220
|
+
return bool(_sel.select([fd], [], [], timeout)[0])
|
|
1221
|
+
except (OSError, ValueError):
|
|
1222
|
+
return False
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _read_key_raw(fd: int) -> str:
|
|
1226
|
+
"""Read and decode one raw keypress, including escape sequences and mouse
|
|
1227
|
+
events, into a named key string."""
|
|
1228
|
+
if _IS_WINDOWS:
|
|
1229
|
+
ch = msvcrt.getwch()
|
|
1230
|
+
if ch in ('\x00', '\xe0'):
|
|
1231
|
+
ext = msvcrt.getwch()
|
|
1232
|
+
return {
|
|
1233
|
+
'H': 'UP', 'P': 'DOWN', 'K': 'LEFT', 'M': 'RIGHT',
|
|
1234
|
+
'G': 'HOME', 'O': 'END', 'I': 'PGUP', 'Q': 'PGDN', 'S': 'DELETE',
|
|
1235
|
+
'R': 'INSERT',
|
|
1236
|
+
}.get(ext, '')
|
|
1237
|
+
if ch == '\r': return 'ENTER'
|
|
1238
|
+
if ch == '\x08': return 'BACKSPACE'
|
|
1239
|
+
if ch == '\x03': return 'CTRL_C'
|
|
1240
|
+
if ch == '\t': return 'TAB'
|
|
1241
|
+
if ch == ' ': return 'SPACE'
|
|
1242
|
+
return ch
|
|
1243
|
+
|
|
1244
|
+
ch = os.read(fd, 1)
|
|
1245
|
+
if ch == b'\x1b':
|
|
1246
|
+
try:
|
|
1247
|
+
# A lone Esc is just this byte; an arrow/function key sends more in
|
|
1248
|
+
# the same burst. Raw mode's read blocks while nothing is pending, so
|
|
1249
|
+
# peek first, or Esc looks dead until the *next* keypress
|
|
1250
|
+
# arrives to unblock the read, and that keypress is then swallowed
|
|
1251
|
+
# as part of the sequence, so Esc would only work on a second press.
|
|
1252
|
+
if not _byte_ready(fd, _ESC_SEQ_TIMEOUT):
|
|
1253
|
+
return 'ESC'
|
|
1254
|
+
ch2 = os.read(fd, 1)
|
|
1255
|
+
if ch2 == b'O':
|
|
1256
|
+
# SS3: some terminals send ESC O A for the arrows while in
|
|
1257
|
+
# application-cursor mode.
|
|
1258
|
+
ss3 = os.read(fd, 1).decode('utf-8', errors='replace')
|
|
1259
|
+
return {'A': 'UP', 'B': 'DOWN', 'C': 'RIGHT', 'D': 'LEFT',
|
|
1260
|
+
'H': 'HOME', 'F': 'END'}.get(ss3, 'ESC')
|
|
1261
|
+
if ch2 == b'[':
|
|
1262
|
+
ch3 = os.read(fd, 1)
|
|
1263
|
+
seq = ch3.decode('utf-8', errors='replace')
|
|
1264
|
+
if seq == '<':
|
|
1265
|
+
# SGR mouse event: \033[<btn;col;row{M|m}
|
|
1266
|
+
buf = ''
|
|
1267
|
+
while len(buf) < 24:
|
|
1268
|
+
# Same guard as the bare Esc above: a truncated mouse
|
|
1269
|
+
# report would otherwise block the whole UI until the
|
|
1270
|
+
# next keypress arrived.
|
|
1271
|
+
if not _byte_ready(fd, _ESC_SEQ_TIMEOUT):
|
|
1272
|
+
return 'ESC'
|
|
1273
|
+
c = os.read(fd, 1).decode('utf-8', errors='replace')
|
|
1274
|
+
if c in ('M', 'm'):
|
|
1275
|
+
parts = buf.split(';')
|
|
1276
|
+
if len(parts) == 3:
|
|
1277
|
+
try:
|
|
1278
|
+
btn, col, row = int(parts[0]), int(parts[1]), int(parts[2])
|
|
1279
|
+
if c == 'm':
|
|
1280
|
+
return f'MOUSE_RELEASE:{btn}:{row}:{col}'
|
|
1281
|
+
if btn == 64: return 'SCROLL_UP'
|
|
1282
|
+
if btn == 65: return 'SCROLL_DOWN'
|
|
1283
|
+
if btn in (0, 1, 2): return f'MOUSE_CLICK:{btn}:{row}:{col}'
|
|
1284
|
+
except ValueError:
|
|
1285
|
+
pass
|
|
1286
|
+
return 'ESC'
|
|
1287
|
+
buf += c
|
|
1288
|
+
return 'ESC'
|
|
1289
|
+
if seq.isdigit():
|
|
1290
|
+
# ESC [ <number> ~ : page/home/end/delete/insert. Read the
|
|
1291
|
+
# whole number, or PgUp and PgDn would come through as 'ESC'.
|
|
1292
|
+
num, term = seq, ''
|
|
1293
|
+
while len(num) < 4 and _byte_ready(fd, _ESC_SEQ_TIMEOUT):
|
|
1294
|
+
c = os.read(fd, 1).decode('utf-8', errors='replace')
|
|
1295
|
+
if c.isdigit():
|
|
1296
|
+
num += c
|
|
1297
|
+
continue
|
|
1298
|
+
term = c
|
|
1299
|
+
break
|
|
1300
|
+
if term == ';':
|
|
1301
|
+
# Modified form (ESC [ 1;5A = Ctrl-Up): drain to the
|
|
1302
|
+
# final letter and treat it as the unmodified key.
|
|
1303
|
+
while _byte_ready(fd, _ESC_SEQ_TIMEOUT):
|
|
1304
|
+
c = os.read(fd, 1).decode('utf-8', errors='replace')
|
|
1305
|
+
if c.isalpha():
|
|
1306
|
+
term = c
|
|
1307
|
+
break
|
|
1308
|
+
if term.isalpha():
|
|
1309
|
+
return {'A': 'UP', 'B': 'DOWN', 'C': 'RIGHT', 'D': 'LEFT',
|
|
1310
|
+
'H': 'HOME', 'F': 'END'}.get(term, 'ESC')
|
|
1311
|
+
return {'1': 'HOME', '2': 'INSERT', '3': 'DELETE', '4': 'END',
|
|
1312
|
+
'5': 'PGUP', '6': 'PGDN', '7': 'HOME', '8': 'END',
|
|
1313
|
+
}.get(num, 'ESC')
|
|
1314
|
+
mapped = {
|
|
1315
|
+
'A': 'UP', 'B': 'DOWN', 'C': 'RIGHT', 'D': 'LEFT',
|
|
1316
|
+
'H': 'HOME', 'F': 'END',
|
|
1317
|
+
'Z': 'BACKTAB', # Shift+Tab
|
|
1318
|
+
'I': 'FOCUS_IN', 'O': 'FOCUS_OUT',
|
|
1319
|
+
}.get(seq, 'ESC')
|
|
1320
|
+
if mapped == 'FOCUS_IN':
|
|
1321
|
+
# Regained focus: force the now-playing box to repaint (it may
|
|
1322
|
+
# be stale from a background change while we were unfocused).
|
|
1323
|
+
invalidate_footer_box()
|
|
1324
|
+
return mapped
|
|
1325
|
+
return 'ESC'
|
|
1326
|
+
except (OSError, EOFError):
|
|
1327
|
+
return 'ESC'
|
|
1328
|
+
# A non-ASCII character (e.g. an accented letter) is 2-4 bytes in UTF-8, but
|
|
1329
|
+
# os.read(fd, 1) only grabbed the lead byte. Pull the continuation bytes so
|
|
1330
|
+
# the whole codepoint decodes to one character instead of several U+FFFD.
|
|
1331
|
+
b0 = ch[0]
|
|
1332
|
+
if b0 >= 0x80:
|
|
1333
|
+
if b0 >= 0xF0: n_cont = 3
|
|
1334
|
+
elif b0 >= 0xE0: n_cont = 2
|
|
1335
|
+
elif b0 >= 0xC0: n_cont = 1
|
|
1336
|
+
else: n_cont = 0 # stray continuation byte; nothing to gather
|
|
1337
|
+
for _ in range(n_cont):
|
|
1338
|
+
ch += os.read(fd, 1)
|
|
1339
|
+
decoded = ch.decode('utf-8', errors='replace')
|
|
1340
|
+
if decoded in ('\r', '\n'): return 'ENTER'
|
|
1341
|
+
if decoded in ('\x7f', '\x08'): return 'BACKSPACE'
|
|
1342
|
+
if decoded == ' ': return 'SPACE'
|
|
1343
|
+
if decoded == '\x03': return 'CTRL_C'
|
|
1344
|
+
if decoded == '\t': return 'TAB'
|
|
1345
|
+
return decoded
|
|
1346
|
+
|
|
1347
|
+
def _visible_rows() -> int:
|
|
1348
|
+
"""Total lines a list widget may emit: the full terminal height minus the
|
|
1349
|
+
status bar (1) and the top+bottom vertical margins. Callers subtract their
|
|
1350
|
+
OWN chrome (header, message, indicators, hints); do not double-count it
|
|
1351
|
+
here, or lists show a premature "N more"."""
|
|
1352
|
+
_, rows = ui.get_terminal_size()
|
|
1353
|
+
# Reserve the status-bar row, plus the now-playing box's rows whenever
|
|
1354
|
+
# background audio is active, so lists never collide with it.
|
|
1355
|
+
reserve = 1 + ui.footer_height()
|
|
1356
|
+
return max(4, rows - reserve - 2 * ui.MARGIN_V)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _rows() -> int:
|
|
1360
|
+
"""Terminal height in rows."""
|
|
1361
|
+
return ui.get_terminal_height()
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _hint_lines(*pairs, extra="") -> list[str]:
|
|
1365
|
+
"""The hint bar rendered as a list of lines rather than one newline-joined string."""
|
|
1366
|
+
return _hint(*pairs, extra=extra).splitlines()
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def _wrap_bordered_input_lines(text: str, content_width: int) -> list[str]:
|
|
1370
|
+
"""Word-wrap text to `content_width`, preserving blank lines as empty entries."""
|
|
1371
|
+
lines: list[str] = []
|
|
1372
|
+
for raw_line in text.split("\n"):
|
|
1373
|
+
if raw_line == "":
|
|
1374
|
+
lines.append("")
|
|
1375
|
+
else:
|
|
1376
|
+
wrapped = textwrap.wrap(raw_line, width=content_width, drop_whitespace=False) or [""]
|
|
1377
|
+
lines.extend(wrapped)
|
|
1378
|
+
return lines
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
class _Widget:
|
|
1382
|
+
"""
|
|
1383
|
+
Paints a widget's lines from row 1 through the shared screen model. The
|
|
1384
|
+
first frame takes the screen over without a clear; after a resize
|
|
1385
|
+
(anchor_reset) it clears and repaints.
|
|
1386
|
+
"""
|
|
1387
|
+
|
|
1388
|
+
def __init__(self, fd: int) -> None:
|
|
1389
|
+
"""No frame painted yet."""
|
|
1390
|
+
self.fd = fd
|
|
1391
|
+
self.row = None # anchor row, 1-based
|
|
1392
|
+
self.last_h = 0
|
|
1393
|
+
self._full = False # whether we own the full screen
|
|
1394
|
+
|
|
1395
|
+
def anchor_reset(self) -> None:
|
|
1396
|
+
"""Called on resize (or after another view owned the screen): clears and
|
|
1397
|
+
redraws from scratch next render."""
|
|
1398
|
+
self.row = None
|
|
1399
|
+
self._full = True
|
|
1400
|
+
|
|
1401
|
+
def render(self, lines: list) -> None:
|
|
1402
|
+
"""Paint `lines` from row 1, diffed against what is already on screen.
|
|
1403
|
+
|
|
1404
|
+
Only rows whose content changed are written, in one buffered frame with
|
|
1405
|
+
no newlines and no erase-to-end-of-screen, so the frame can't be flushed
|
|
1406
|
+
half-drawn, and the rows this widget doesn't own (the now-playing box, the
|
|
1407
|
+
status bar) are left exactly as they are instead of being wiped and
|
|
1408
|
+
restamped on every keystroke.
|
|
1409
|
+
"""
|
|
1410
|
+
mv = ui.MARGIN_V
|
|
1411
|
+
rows = ui.get_terminal_height()
|
|
1412
|
+
|
|
1413
|
+
# Wrap content with vertical margins: mv blank rows on top, mv reserved
|
|
1414
|
+
# rows before the status bar at the bottom. The box's band is excluded so
|
|
1415
|
+
# the two writers never own the same row (a shrinking box would otherwise
|
|
1416
|
+
# blank rows this diff believes it still owns).
|
|
1417
|
+
padded: list[str] = [''] * mv + list(lines)
|
|
1418
|
+
limit = rows - 1 - max(mv, footer_height_for_layout())
|
|
1419
|
+
padded = padded[:max(0, limit)]
|
|
1420
|
+
|
|
1421
|
+
if self._full or self.row is None:
|
|
1422
|
+
if self._full and _screen:
|
|
1423
|
+
# A real clear only for a resize (or after another view owned the
|
|
1424
|
+
# screen): the terminal reflowed, so nothing on it can be trusted.
|
|
1425
|
+
sys.stdout.write("\033[H\033[2J" + C.HIDE)
|
|
1426
|
+
screen_invalidate()
|
|
1427
|
+
else:
|
|
1428
|
+
# First frame of a new widget: take the screen over in the paint
|
|
1429
|
+
# itself, so moving between screens never shows a blank one.
|
|
1430
|
+
screen_takeover_next()
|
|
1431
|
+
self.row = 1
|
|
1432
|
+
self._full = False
|
|
1433
|
+
|
|
1434
|
+
frame: dict[int, str] = {i + 1: line for i, line in enumerate(padded)}
|
|
1435
|
+
# Blank any rows a previous, taller frame left behind.
|
|
1436
|
+
for i in range(len(padded), self.last_h):
|
|
1437
|
+
frame[i + 1] = ""
|
|
1438
|
+
self.last_h = len(padded)
|
|
1439
|
+
|
|
1440
|
+
# The status bar and the box join the same frame, so everything lands in
|
|
1441
|
+
# one flush, but each row still only costs anything if it changed.
|
|
1442
|
+
frame[rows] = ui.get_status_line()
|
|
1443
|
+
frame = _takeover_rows(frame)
|
|
1444
|
+
parts = [C.HIDE]
|
|
1445
|
+
for row in sorted(frame):
|
|
1446
|
+
parts.append(screen_row_segment(row, frame[row]))
|
|
1447
|
+
parts.append(footer_box_segment())
|
|
1448
|
+
out = "".join(p for p in parts if p)
|
|
1449
|
+
if out != C.HIDE:
|
|
1450
|
+
sys.stdout.write(out)
|
|
1451
|
+
sys.stdout.flush()
|
|
1452
|
+
|
|
1453
|
+
def clear(self) -> None:
|
|
1454
|
+
"""Clear the screen and reset anchor state, cursor still hidden.
|
|
1455
|
+
|
|
1456
|
+
The cursor is only ever shown for a text caret, or by
|
|
1457
|
+
`ui.exit_alt_screen()` when the app hands the terminal back.
|
|
1458
|
+
"""
|
|
1459
|
+
sys.stdout.write("\033[H\033[3J\033[J" + C.HIDE)
|
|
1460
|
+
sys.stdout.flush()
|
|
1461
|
+
screen_invalidate()
|
|
1462
|
+
self.last_h = 0
|
|
1463
|
+
self.row = None
|
|
1464
|
+
self._full = True
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
_register_screen_hooks()
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
hint = _hint # the public name for the hint bar
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def run_dashboard(render, interval: float = 1.0, quit_action: str = "list.quit", on_quit=None,
|
|
1474
|
+
poll: float = 0.05, on_key=None) -> None:
|
|
1475
|
+
"""Runs a live, tick-driven view through a _Widget, so it resizes and
|
|
1476
|
+
paints like every other widget here. Returns when a key of the
|
|
1477
|
+
`quit_action` binding (see backbone.keys) is pressed.
|
|
1478
|
+
|
|
1479
|
+
`render()` takes no arguments and returns the whole frame as a list of
|
|
1480
|
+
lines, each carrying its own left-margin indent (see header_box()). It
|
|
1481
|
+
runs once every `interval` seconds; keypresses and resizes are checked
|
|
1482
|
+
every `poll` seconds regardless, and a resize renders at once.
|
|
1483
|
+
|
|
1484
|
+
`on_key(key)`, if given, is called for any other keypress (e.g. "s" for
|
|
1485
|
+
a settings screen). It may open select()/text()/confirm() itself; the
|
|
1486
|
+
view is cleared and redrawn when it returns.
|
|
1487
|
+
|
|
1488
|
+
`on_quit()`, if given, runs after the terminal is restored (cursor
|
|
1489
|
+
back, raw mode undone) - the place for a "stop the background work
|
|
1490
|
+
too?" confirm().
|
|
1491
|
+
|
|
1492
|
+
When stdin is not a terminal, keys can't be read: it renders on a plain
|
|
1493
|
+
time.sleep(interval) loop and never checks for the quit key, so the
|
|
1494
|
+
caller has to be stopped from outside.
|
|
1495
|
+
"""
|
|
1496
|
+
fd = sys.stdin.fileno()
|
|
1497
|
+
is_tty = sys.stdin.isatty()
|
|
1498
|
+
old_settings = _get_term_attrs(fd) if is_tty else None
|
|
1499
|
+
if is_tty:
|
|
1500
|
+
_set_raw(fd)
|
|
1501
|
+
|
|
1502
|
+
w = _Widget(fd)
|
|
1503
|
+
screen_takeover_next()
|
|
1504
|
+
last_render = 0.0
|
|
1505
|
+
try:
|
|
1506
|
+
while True:
|
|
1507
|
+
resized = ui.consume_resize()
|
|
1508
|
+
if resized:
|
|
1509
|
+
ui.clear_screen()
|
|
1510
|
+
w.anchor_reset()
|
|
1511
|
+
now = time.monotonic()
|
|
1512
|
+
if resized or now - last_render >= interval:
|
|
1513
|
+
w.render(render())
|
|
1514
|
+
last_render = now
|
|
1515
|
+
if is_tty:
|
|
1516
|
+
if _wait_for_keypress(poll):
|
|
1517
|
+
key = _read_key(fd)
|
|
1518
|
+
if keys.pressed(key, quit_action):
|
|
1519
|
+
break
|
|
1520
|
+
if on_key is not None:
|
|
1521
|
+
on_key(key)
|
|
1522
|
+
ui.clear_screen()
|
|
1523
|
+
w.anchor_reset()
|
|
1524
|
+
else:
|
|
1525
|
+
time.sleep(interval)
|
|
1526
|
+
finally:
|
|
1527
|
+
if is_tty:
|
|
1528
|
+
_restore_term_attrs(fd, old_settings)
|
|
1529
|
+
sys.stdout.write("\033[?25h\n")
|
|
1530
|
+
|
|
1531
|
+
if on_quit is not None:
|
|
1532
|
+
on_quit()
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _demo() -> None:
|
|
1536
|
+
"""Self-check for the pure (non-interactive) logic in this module, the
|
|
1537
|
+
parts that run without a terminal. Doesn't touch raw mode, key reading,
|
|
1538
|
+
or screen painting (those need a real tty).
|
|
1539
|
+
Run directly: `python3 -m backbone.prompt.core`.
|
|
1540
|
+
"""
|
|
1541
|
+
assert _norm(["a", "b"])[0].title == "a"
|
|
1542
|
+
assert _norm([{"name": "x", "value": 1}])[0].value == 1
|
|
1543
|
+
c = Choice("t", value=5, checked=True)
|
|
1544
|
+
assert c.value == 5 and c.checked
|
|
1545
|
+
|
|
1546
|
+
cols = [Column(flex=True, min_width=4), Column(pin=True, min_width=3)]
|
|
1547
|
+
rows = [["short", "1"], ["a much longer title here", "22"]]
|
|
1548
|
+
widths = _table_widths(rows, cols, eff=40, pointer_w=4, right_margin=0)
|
|
1549
|
+
assert all(w >= 0 for w in widths), widths
|
|
1550
|
+
|
|
1551
|
+
assert block_cursor_width("abc", 1) == 3
|
|
1552
|
+
assert block_cursor_width("abc", 5) == 4
|
|
1553
|
+
|
|
1554
|
+
tokens = _hint_key_tokens("↑↓")
|
|
1555
|
+
assert [t[2] for t in tokens] == ["UP", "DOWN"]
|
|
1556
|
+
assert _hint_key_tokens("^N") == [(0, 2, "\x0e")]
|
|
1557
|
+
|
|
1558
|
+
print("backbone.prompt.core self-check OK")
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
if __name__ == "__main__":
|
|
1562
|
+
_demo()
|