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,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()