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,1280 @@
1
+ """Picking from lists: select (single, multi, with row actions), live_select
2
+ (a list driven by a query), and confirm."""
3
+ from __future__ import annotations
4
+ import re
5
+ import sys
6
+ from typing import Any, Callable, Literal, overload
7
+ from backbone.prompt.core import (
8
+ _COLUMNS_MAX_WIDTH, _EDGE_MARGIN, _get_term_attrs, _set_raw, _restore_term_attrs,
9
+ _wait_for_keypress, _table_widths, _render_table_row, _clip_ansi, _norm, block_cursor,
10
+ _read_key, _visible_rows, _cols, _Widget, _hint_pin_target, screen_takeover_next,
11
+ Choice, Column, rounded_header,
12
+ )
13
+ from backbone import keys, ui
14
+ from backbone.nav import QuitToTerminal
15
+ from backbone.prompt import chrome
16
+ from backbone.prompt.chrome import (
17
+ append_chrome, CHROME_HANDLED, chrome_hint_lines, CHROME_REDRAW, consume_chrome, disable_mouse, enable_mouse, move_hint, _plain,
18
+ )
19
+ from backbone.prompt.core import C
20
+ from backbone.prompt.core import edit_line
21
+
22
+
23
+ class ListPlace:
24
+ """Where a list that is rebuilt each time round was left: the highlighted
25
+ row's value, so a re-sort or an edit comes back to the same item, and its
26
+ position for when that item has gone. Pass the same one to every select()
27
+ of that list; reset() lands the next one on the first row."""
28
+ def __init__(self) -> None:
29
+ self.value: Any = None
30
+ self.pos = 0
31
+
32
+ def reset(self) -> None:
33
+ self.value, self.pos = None, 0
34
+
35
+
36
+ @overload
37
+ def select(message: str, choices: list, *,
38
+ header: list | None | Callable[[], list[str]] = ...,
39
+ extra_hints: dict[str, str] | None = ...,
40
+ index: int = ...,
41
+ shortcuts: dict[str, str] | None = ...,
42
+ columns: list | None = ...,
43
+ multi: Literal[False] = ...,
44
+ interlock_category_callback: Callable[[Any], str] | None = ...,
45
+ on_inspect: Callable[[Any], None] | None = ...,
46
+ inspect_key: str = ...,
47
+ row_actions: dict[str, Callable[[Any], None]] | None = ...,
48
+ row_action_hints: dict[str, str] | None = ...,
49
+ allow_back: bool = ...,
50
+ place: ListPlace | None = ...,
51
+ actions: list[tuple[str, str, str]] | None = ...,
52
+ on_move: Callable[[Any, int], bool] | None = ...,
53
+ ) -> Any: ...
54
+
55
+
56
+ @overload
57
+ def select(message: str, choices: list, *,
58
+ header: list | None | Callable[[], list[str]] = ...,
59
+ extra_hints: dict[str, str] | None = ...,
60
+ index: int = ...,
61
+ shortcuts: dict[str, str] | None = ...,
62
+ columns: list | None = ...,
63
+ multi: Literal[True],
64
+ interlock_category_callback: Callable[[Any], str] | None = ...,
65
+ on_inspect: Callable[[Any], None] | None = ...,
66
+ inspect_key: str = ...,
67
+ row_actions: dict[str, Callable[[Any], None]] | None = ...,
68
+ row_action_hints: dict[str, str] | None = ...,
69
+ allow_back: bool = ...,
70
+ place: ListPlace | None = ...,
71
+ ) -> list[Any] | None: ...
72
+
73
+
74
+ keys.define("list", "Lists", [
75
+ ("up", ("UP",), "previous row"),
76
+ ("down", ("DOWN",), "next row"),
77
+ ("page_up", ("PGUP",), "a page up"),
78
+ ("page_down", ("PGDN",), "a page down"),
79
+ ("top", ("HOME",), "first row"),
80
+ ("bottom", ("END",), "last row"),
81
+ ("choose", ("ENTER", "RIGHT"), "choose the row"),
82
+ ("toggle", ("SPACE",), "tick the row (lists with ticks)"),
83
+ ("toggle_all", ("a", "A"), "tick or clear every row (lists with ticks)"),
84
+ ("back", ("ESC", "b", "LEFT"), "back"),
85
+ ("quit", ("q", "Q"), "quit the app"),
86
+ ("sections", ("/",), "a long list's sections, or the whole list"),
87
+ ("row_options", ("o",), "everything you can do with the row"),
88
+ ("list_options", ("O",), "everything you can do with the whole list"),
89
+ ])
90
+ # The live search lists: typing goes into the query, so their keys are the
91
+ # ones that can't be typed.
92
+ keys.define("search", "Search lists", [
93
+ ("up", ("UP",), "previous result"),
94
+ ("down", ("DOWN",), "next result"),
95
+ ("page_up", ("PGUP",), "five results up"),
96
+ ("page_down", ("PGDN",), "five results down"),
97
+ ("choose", ("ENTER",), "choose the result"),
98
+ ("next_section", ("TAB",), "next section"),
99
+ ("prev_section", ("BACKTAB",), "previous section"),
100
+ ("back", ("ESC",), "back"),
101
+ ])
102
+ keys.define("confirm", "Yes/no questions", [
103
+ ("yes", ("y", "Y"), "yes"),
104
+ ("no", ("n", "N"), "no"),
105
+ ("default", ("ENTER",), "the default answer"),
106
+ ("back", ("ESC",), "back (answers no)"),
107
+ ])
108
+ L = keys.label
109
+
110
+
111
+ def _scroll(viewport: int, cursor: int, vis: int, items: list) -> int:
112
+ """The first row to show so the cursor is in view. A section heading (the
113
+ run of disabled rows just above the cursor) comes into view with its first
114
+ row, so the top of a list never reads "1 above" with only a heading there.
115
+ Growing the window (or deleting rows) leaves the viewport further down than
116
+ it needs to be, leaving "N above" with blank space below: it's pulled back
117
+ so the last row of the list sits on the last visible row at most."""
118
+ if cursor < viewport:
119
+ viewport = cursor
120
+ elif cursor >= viewport + vis:
121
+ viewport = cursor - vis + 1
122
+ top = cursor
123
+ while top > 0 and items[top - 1].disabled:
124
+ top -= 1
125
+ if top < viewport and cursor - top < vis:
126
+ viewport = top
127
+ return max(0, min(viewport, len(items) - vis))
128
+
129
+
130
+ def options_menu(title: str, entries: list) -> Any:
131
+ """A menu of what can be done, each with the key that does it directly:
132
+ `entries` are (label, key text, value); returns the value picked, or None."""
133
+ if not entries:
134
+ return None
135
+ choices = [Choice(title=label, value=i, cells=[label, key_text])
136
+ for i, (label, key_text, _v) in enumerate(entries)]
137
+ i = select("", choices, columns=[Column(style='primary'), Column(style='dynamic-dim', flex=True)],
138
+ header=lambda: rounded_header(title or "Options", "", "options"))
139
+ return None if i is None else entries[i][2]
140
+
141
+
142
+ # A list in sections (separator() headings) this much longer than the screen
143
+ # opens as its section titles: ↵ opens one as its own list, / shows the whole.
144
+ _SECTION_SLACK = 1.2
145
+ _TO_WHOLE, _TO_SECTIONS = object(), object()
146
+ # Per sectioned list (keyed by its section titles): whether the whole list was
147
+ # asked for, and which section is open, so a caller that redraws its list after
148
+ # each change (Settings, Key bindings) comes back to the same section.
149
+ _section_memo: dict = {}
150
+
151
+
152
+ def _sections(items: list) -> list[tuple[str, list]] | None:
153
+ """(title, rows) for each titled section, or None when the list isn't
154
+ headed throughout. A blank separator stays inside its section; a greyed
155
+ row (disabled, with cells) is a row, not a heading."""
156
+ out: list[tuple[str, list]] = []
157
+ for it in items:
158
+ if it.disabled and not it.cells and str(it.title).strip():
159
+ out.append((str(it.title), []))
160
+ elif not out:
161
+ return None # rows before the first heading
162
+ else:
163
+ out[-1][1].append(it)
164
+ return out if len(out) > 1 else None
165
+
166
+
167
+ def select(message: str, choices: list, **kw) -> Any:
168
+ """Arrow keys to navigate; Enter / → to confirm; ← / b / Esc → None; q
169
+ quits the app. See _select_flat for every option.
170
+
171
+ A single-choice list in sections that is much taller than the screen
172
+ (_SECTION_SLACK) opens as its section titles; ↵ opens a section as its own
173
+ list (Esc back to the titles) and list.sections (/) switches between that
174
+ and the whole list, remembered per list."""
175
+ items = _norm(choices)
176
+ sections = None if kw.get('multi') else _sections(items)
177
+ if not sections:
178
+ return _select_flat(message, items, **kw)
179
+ # 'whole': None until / is pressed: then the list's length decides, each time.
180
+ memo = _section_memo.setdefault(tuple(t for t, _ in sections), {'whole': None, 'open': None})
181
+ if memo['whole'] is None and len(items) <= _visible_rows() * _SECTION_SLACK:
182
+ return _select_flat(message, items, **kw)
183
+ # The row the caller wants to land on (where the list was left).
184
+ place = kw.get('place')
185
+ want = (place.value if place is not None and place.value is not None
186
+ else items[max(0, min(kw.get('index', 0), len(items) - 1))].value)
187
+ def _with_toggle(to, label) -> dict:
188
+ return {**kw, 'shortcuts': {**(kw.get('shortcuts') or {}), 'list.sections': to},
189
+ 'extra_hints': {**(kw.get('extra_hints') or {}), 'list.sections': label}}
190
+
191
+ while True:
192
+ if memo['whole']:
193
+ res = _select_flat(message, items, **_with_toggle(_TO_SECTIONS, "sections"))
194
+ if res is _TO_SECTIONS:
195
+ memo['whole'] = False
196
+ continue
197
+ return res
198
+ titles = [t for t, _ in sections]
199
+ if memo['open'] in titles:
200
+ rows = sections[titles.index(memo['open'])][1]
201
+ pos = next((i for i, it in enumerate(rows) if not it.disabled and it.value == want), 0)
202
+ res = _select_flat(memo['open'] if not message else f"{message} {memo['open']}", rows,
203
+ **{**_with_toggle(_TO_WHOLE, "whole list"), 'index': pos})
204
+ if res is _TO_WHOLE:
205
+ memo['whole'], memo['open'] = True, None
206
+ continue
207
+ if res is None: # back to the section titles
208
+ want = memo['open']
209
+ memo['open'] = None
210
+ continue
211
+ return res
212
+ here = next((t for t, rows in sections if any(it.value == want for it in rows)), want)
213
+ res = _select_flat(message, [Choice(t, value=t, cells=[t, f"{sum(not it.disabled for it in rows)}"])
214
+ for t, rows in sections],
215
+ header=kw.get('header'), allow_back=kw.get('allow_back', True),
216
+ columns=[Column(style='primary'), Column(style='dynamic-dim', flex=True)],
217
+ index=titles.index(here) if here in titles else 0,
218
+ shortcuts={'list.sections': _TO_WHOLE}, extra_hints={'list.sections': "whole list"})
219
+ if res is _TO_WHOLE:
220
+ memo['whole'] = True
221
+ continue
222
+ if res is None:
223
+ return None
224
+ memo['open'] = res
225
+ want = None
226
+
227
+
228
+ def _select_flat(message: str, choices: list, *,
229
+ header: list | None | Callable[[], list[str]] = None,
230
+ extra_hints: dict[str, str] | None = None,
231
+ index: int = 0,
232
+ shortcuts: dict[str, str] | None = None,
233
+ columns: list | None = None,
234
+ multi: bool = False,
235
+ interlock_category_callback: Callable[[Any], str] | None = None,
236
+ on_inspect: Callable[[Any], None] | None = None,
237
+ inspect_key: str = 'd',
238
+ row_actions: dict[str, Callable[[Any], None]] | None = None,
239
+ row_action_hints: dict[str, str] | None = None,
240
+ row_edit: Callable[[Any], list] | None = None,
241
+ row_edit_commit: Callable[[Any, str], None] | None = None,
242
+ row_edit_col: int = 1,
243
+ row_edit_key: str = 'e',
244
+ allow_back: bool = True,
245
+ actions: list[tuple[str, str, str]] | None = None,
246
+ on_move: Callable[[Any, int], bool] | None = None,
247
+ place: ListPlace | None = None,
248
+ row_action_applies: Callable[[str, Any], bool] | None = None,
249
+ list_actions: dict[str, Callable[[], Any]] | None = None,
250
+ list_action_hints: dict[str, str] | None = None,
251
+ choose_label: str | None = None,
252
+ ) -> Any:
253
+ """Arrow keys to navigate; Enter / → to confirm; ← / b / Esc → None; q quits the app.
254
+
255
+ When multi=True, Space toggles the current item and Enter returns a list of
256
+ all checked values (possibly empty). Otherwise returns the single selected
257
+ value, or None if cancelled.
258
+
259
+ Args:
260
+ message: Prompt label shown above the list.
261
+ choices: Items: str, dict, or Choice objects.
262
+ header: Optional lines rendered above the prompt: a callable
263
+ returning them, rebuilt every frame, for anything sized to
264
+ the window (a boxed header), or it keeps its first width.
265
+ extra_hints: Extra key→action bindings merged into the hint bar.
266
+ index: Initial cursor position.
267
+ place: A ListPlace to start from and record where the list was left
268
+ (instead of index), for a list rebuilt each time round.
269
+ shortcuts: Optional key→return-value map (single-select only).
270
+ columns: Column layout descriptors (see Column dataclass).
271
+ multi: Enable multi-select mode (Space to toggle, Enter returns list).
272
+ interlock_category_callback: When set, only one category can be checked
273
+ at a time (multi=True only).
274
+ on_inspect: Called with the current row's value when `inspect_key` is
275
+ pressed; runs its own view and returns, leaving selection/checkbox
276
+ state intact (the list redraws afterwards). If it returns something
277
+ other than None, select() returns that instead, for a caller that
278
+ must rebuild the list after the view changed what it shows.
279
+ inspect_key: Key that triggers `on_inspect` (default 'd').
280
+ row_actions: key→callback(current row value) map. Pressing the key runs
281
+ the callback against the highlighted row and stays in the list (like
282
+ on_inspect, but any number of keys), e.g. queue the current track.
283
+ A callback that returns something other than None ends the list
284
+ with that as the result, for a caller that must rebuild it.
285
+ row_action_hints: key→label map surfaced in the hint bar for row_actions.
286
+ row_edit: row value → the values `row_edit_key` cycles that row through,
287
+ its current one first. Each press steps to the next, and one step past
288
+ the last is an inline text field seeded from where you left off, so a
289
+ value that isn't on the list can just be typed. ↑↓ cycle too (a way
290
+ back out of the field), ↵ commits, Esc abandons. Columns-only, since
291
+ the edit happens inside a cell.
292
+ row_edit_commit: called with (row value, chosen text) on ↵.
293
+ row_edit_col: which cell of the row the editing happens in.
294
+ row_edit_key: the key that opens the cycle and advances it (default 'e').
295
+ allow_back: when False, the cancel keys (←/b/Esc) are ignored so the
296
+ list can only move forward (Enter) or quit (q), used for top-level
297
+ menus that have nowhere to go back to.
298
+ actions: (key, label, value) list-wide actions (play all, shuffle…),
299
+ kept out of the rows so the cursor only moves through the list
300
+ itself: each is listed first in the hint bar, and its key (or a
301
+ click on it there) returns value.
302
+ on_move: (row value, -1 up / +1 down) → whether the caller moved it.
303
+ The list.move_up / list.move_down keys call it for the highlighted row, and on
304
+ True the row swaps with its neighbour on screen and the cursor
305
+ follows. Never called past a separator or the ends of the list.
306
+ row_action_applies: (row action key or id, row value) → whether that
307
+ action means something for the row; the others are left out of
308
+ the row's options menu (and do nothing on it).
309
+ list_actions: key or id → callback() acting on the whole list and
310
+ staying in it, like row_actions without a row; labels in
311
+ list_action_hints. A callback returning something other than None
312
+ ends the list with that.
313
+ choose_label: what ↵ does to a row ("Play", "Open"), for the top of
314
+ the row's options menu.
315
+
316
+ Options menus: list.row_options (o) lists everything that can be done
317
+ with the highlighted row (↵, on_inspect, the row actions that apply),
318
+ list.list_options (O) everything for the whole list (actions, shortcuts,
319
+ list_actions), each with its key, so actions without a key are
320
+ reachable too. Picking one does exactly what its key does.
321
+ """
322
+ items = _norm(choices)
323
+ # The list's options menu (O) leads with its actions, then its shortcuts.
324
+ _list_specs = [k for k, _label, _v in (actions or [])] + [k for k in (shortcuts or {})
325
+ if k not in {a[0] for a in actions or []}]
326
+ # Any key below may be given as an action id (backbone.keys): every key
327
+ # bound to it works, and its hint shows them.
328
+ if actions:
329
+ shortcuts = {**(shortcuts or {}), **{k: v for k, _label, v in actions}}
330
+ extra_hints = {**{k: label for k, label, _v in actions}, **(extra_hints or {})}
331
+ # The options menus list these as given (an action may have no key yet),
332
+ # with the caller's labels; an id also works as its own key, for the menu.
333
+ _labels = {**(extra_hints or {}), **(row_action_hints or {}), **(list_action_hints or {})}
334
+ _row_specs = list(row_actions or {})
335
+ _list_act_specs = list(list_actions or {})
336
+ shortcuts = {**keys.expand(shortcuts), **(shortcuts or {})}
337
+ row_actions = {**keys.expand(row_actions), **(row_actions or {})}
338
+ list_actions = {**keys.expand(list_actions), **(list_actions or {})}
339
+ extra_hints = {keys.hint_for(k): v for k, v in (extra_hints or {}).items()}
340
+ row_action_hints = {keys.hint_for(k): v for k, v in (row_action_hints or {}).items()}
341
+ row_action_hints.update({keys.hint_for(k): v for k, v in (list_action_hints or {}).items()})
342
+ inspect_keys = keys.keys_for(inspect_key) + (inspect_key,)
343
+ row_edit_keys = keys.keys_for(row_edit_key)
344
+ row_edit_key = keys.hint_for(row_edit_key) # for the hints
345
+ if not items:
346
+ return None
347
+
348
+ selectable = [i for i, it in enumerate(items) if not it.disabled]
349
+ if not selectable:
350
+ return None
351
+
352
+ def _step(cur: int, direction: int) -> int:
353
+ """Move to the next selectable row, skipping disabled separators."""
354
+ n = len(items)
355
+ nxt = (cur + direction) % n
356
+ steps = 0
357
+ while items[nxt].disabled and steps < n:
358
+ nxt = (nxt + direction) % n
359
+ steps += 1
360
+ return nxt
361
+
362
+ def _nearest_selectable(idx: int) -> int:
363
+ """Closest selectable row to idx (used after page jumps / clamps)."""
364
+ return min(selectable, key=lambda s: abs(s - idx))
365
+
366
+ if place is not None:
367
+ index = next((i for i, it in enumerate(items)
368
+ if place.value is not None and not it.disabled and it.value == place.value),
369
+ place.pos)
370
+ cursor = max(0, min(index, len(items) - 1))
371
+ if items[cursor].disabled:
372
+ cursor = _step(cursor, 1)
373
+ viewport = 0
374
+ fd = sys.stdin.fileno()
375
+ old = _get_term_attrs(fd)
376
+ w = _Widget(fd)
377
+
378
+ # Interlock state for multi-select: track which category is locked
379
+ _locked_category: list[str | None] = [None]
380
+
381
+ def _update_interlock() -> None:
382
+ """Lock selection to the category of the first checked item, disabling every non-matching row."""
383
+ if not multi or interlock_category_callback is None:
384
+ return
385
+ checked = [it for it in items if it.checked]
386
+ if not checked:
387
+ _locked_category[0] = None
388
+ for it in items:
389
+ it.disabled = False
390
+ return
391
+ _locked_category[0] = interlock_category_callback(checked[0].value)
392
+ for it in items:
393
+ if not it.checked:
394
+ it.disabled = (interlock_category_callback(it.value) != _locked_category[0])
395
+
396
+ _update_interlock()
397
+
398
+ base_hints: dict[str, str]
399
+ # Toggle-all ('a') is offered only where it can't misbehave: multi-select with
400
+ # no category interlock and no caller shortcut already bound to 'a'.
401
+ _toggle_all_ok = multi and interlock_category_callback is None and not (
402
+ shortcuts and any(k in shortcuts for k in keys.of("list.toggle_all")))
403
+ _back_hint = {L("list.back", most=2): "back"} if allow_back else {}
404
+ _move = {L("list.up", "list.down"): "move"}
405
+ _end = {L("list.quit"): "quit app", L("list.choose", most=1): "confirm"}
406
+ if multi:
407
+ base_hints = {**_move, L("list.toggle"): "toggle", **_back_hint, **_end}
408
+ if _toggle_all_ok:
409
+ base_hints = {**_move, L("list.toggle"): "toggle", L("list.toggle_all"): "all",
410
+ **_back_hint, **_end}
411
+ else:
412
+ base_hints = {**_move, **_back_hint, **_end}
413
+
414
+ if extra_hints:
415
+ combined_hints = {**extra_hints, **base_hints}
416
+ else:
417
+ combined_hints = base_hints
418
+ # Row-action keys (e.g. queue the current track) sit with the other action
419
+ # hints, before the navigation keys.
420
+ if row_action_hints:
421
+ combined_hints = {**{k: v for k, v in combined_hints.items() if k not in base_hints},
422
+ **row_action_hints, **base_hints}
423
+ if on_move is not None:
424
+ combined_hints = {**{k: v for k, v in combined_hints.items() if k not in base_hints},
425
+ move_hint()[0]: move_hint()[1], **base_hints}
426
+ _opt_hints = {}
427
+ if on_inspect is not None or _row_specs or choose_label:
428
+ _opt_hints[L("list.row_options")] = "options"
429
+ if _list_specs or _list_act_specs:
430
+ _opt_hints[L("list.list_options")] = "list options"
431
+ combined_hints = {**{k: v for k, v in combined_hints.items() if k not in base_hints},
432
+ **_opt_hints, **base_hints}
433
+
434
+ def _name(spec) -> str:
435
+ """A menu label for a key or action id: what the action does (the hint
436
+ text is too terse for a menu), else what the caller calls the key."""
437
+ text = keys.describe(spec) or _labels.get(spec) or str(spec)
438
+ return text[:1].upper() + text[1:]
439
+
440
+ def _row_menu() -> list:
441
+ it = items[cursor]
442
+ if it.disabled:
443
+ return []
444
+ out = []
445
+ if choose_label and not multi:
446
+ out.append((choose_label, L("list.choose", most=1), ("choose", None)))
447
+ if on_inspect is not None:
448
+ out.append((_name(inspect_key), keys.hint_for(inspect_key), ("key", inspect_key)))
449
+ for spec in _row_specs:
450
+ if row_action_applies is None or row_action_applies(spec, it.value):
451
+ out.append((_name(spec), keys.hint_for(spec), ("key", spec)))
452
+ return out
453
+
454
+ def _list_menu() -> list:
455
+ return ([(_name(s), keys.hint_for(s), ("key", s)) for s in _list_specs]
456
+ + [(_name(s), keys.hint_for(s), ("list", s)) for s in _list_act_specs])
457
+
458
+ # Inline row edit (opt-in, see row_edit): the cycle sits at _edit_i over
459
+ # _edit_opts, with one position past the end being the text field. While
460
+ # typing, every key belongs to the buffer, including 'q' and the cycle key
461
+ # itself, which is why leaving the field is ↑↓/↵/Esc and nothing else.
462
+ _edit_on = False
463
+ _edit_opts: list = []
464
+ _edit_i = 0
465
+ _edit_buf: list = []
466
+ _edit_pos = 0
467
+ _edit_hints = {row_edit_key: "next", "↑↓": "cycle",
468
+ "↵": "set", "esc": "cancel"}
469
+ if row_edit is not None:
470
+ combined_hints = {row_edit_key: "edit",
471
+ **{k: v for k, v in combined_hints.items() if k != row_edit_key}}
472
+
473
+ _last_hlen = [0]
474
+ # Maps a visible item index → its ANSI-stripped rendered text, so a mouse
475
+ # click can tell whether it landed on a printed character or blank space.
476
+ _row_plain: dict[int, str] = {}
477
+ # Maps an absolute (row, col) on a hint line → the key that clicking that
478
+ # bright glyph should replay through the normal key handling below.
479
+ _hint_cells: dict[tuple[int, int], str] = {}
480
+
481
+ def _header_lines() -> list[str]:
482
+ if header is None:
483
+ return []
484
+ return header() if callable(header) else list(header)
485
+
486
+ def _help_key_free() -> bool:
487
+ """Whether `?` can toggle the hints here: not bound by this list, and
488
+ not being typed into a cell."""
489
+ def bound(k):
490
+ return (k in shortcuts or k in row_actions
491
+ or (on_inspect is not None and k in inspect_keys)
492
+ or (row_edit is not None and k in row_edit_keys))
493
+ return not (_edit_on or any(bound(k) for k in keys.of("global.help")))
494
+
495
+ def _editing_text() -> bool:
496
+ """Whether the cycle has stepped past its options into the text field."""
497
+ return _edit_i >= len(_edit_opts)
498
+
499
+ def _edit_cell() -> list:
500
+ """The cell under edit, as styled segments: a cycled option, or the live
501
+ text field. Segments rather than raw ANSI: the table measures a cell by
502
+ the length of its text, so escape codes inside one would be counted as
503
+ visible and the cell truncated to nothing."""
504
+ if not _editing_text():
505
+ # ▾ marks a value being stepped through rather than one already set.
506
+ return [("▾ ", 'accent'), (_edit_opts[_edit_i], 'primary')]
507
+ text = "".join(_edit_buf)
508
+ head = [("✎ ", 'accent')]
509
+ if _edit_pos >= len(text):
510
+ return head + [(text, 'primary'), (" ", 'cursor')]
511
+ return head + [(text[:_edit_pos], 'primary'), (text[_edit_pos], 'cursor'),
512
+ (text[_edit_pos + 1:], 'primary')]
513
+
514
+ def _lines():
515
+ nonlocal viewport
516
+ cols = _cols()
517
+ # Refresh the now-playing box height up front so this frame's row budget
518
+ # (vis) and hint pinning match the box that render() will actually draw;
519
+ # otherwise a just-appeared box paints over the pinned hints until the
520
+ # next redraw (hints missing until you click/navigate).
521
+ ui.footer_lines(ui.get_terminal_width())
522
+ h_lines = _header_lines()
523
+ _last_hlen[0] = len(h_lines)
524
+ _row_plain.clear()
525
+
526
+ max_header_w = 0
527
+ for hl in h_lines:
528
+ plain_hl = ui.strip_ansi(hl)
529
+ plain_hl = re.sub(r'[╭─│╰╮╯┌┐└┘├┤┬┴┼═║╔╗╚╝]', '', plain_hl).strip()
530
+ max_header_w = max(max_header_w, ui.visual_len(plain_hl))
531
+
532
+ layout_constraint = " " * max_header_w if (0 < max_header_w < cols - 20) else ""
533
+
534
+ # The transport keys are surfaced here whenever background audio is
535
+ # playing (recomputed each render so they appear/vanish live); see
536
+ # `chrome_hint_pairs`.
537
+ # One source for both the row budget below and the bar actually painted
538
+ # at the end of this function: they must agree or the list mis-sizes.
539
+ hints_now = _edit_hints if _edit_on else combined_hints
540
+ hint_lines = chrome_hint_lines(hints_now, extra=layout_constraint)
541
+
542
+ # Non-item lines this widget emits: header + message + the two
543
+ # above/below indicator rows (always present) + hints.
544
+ fixed_overhead = len(h_lines) + len(hint_lines) + 3
545
+ vis = max(2, _visible_rows() - fixed_overhead)
546
+
547
+ n = len(items)
548
+ viewport = _scroll(viewport, cursor, vis, items)
549
+
550
+ out = h_lines[:]
551
+ out.append(f" {C.DIM}{message}{C.RESET}")
552
+ out.append(f" {C.DIM}╵ {viewport} above{C.RESET}" if viewport > 0 else "")
553
+
554
+ # Structured columns: compute table widths once from each item's cells.
555
+ # Rows without cells (headings/separators) fall back to plain rendering.
556
+ eff: int = 0
557
+ col_widths: list[int] = []
558
+
559
+ def _cells_of(i: int) -> list:
560
+ """A row's cells, with the edited one swapped in while it is live."""
561
+ cells = items[i].cells
562
+ if _edit_on and i == cursor and cells and 0 <= row_edit_col < len(cells):
563
+ cells = list(cells)
564
+ cells[row_edit_col] = _edit_cell()
565
+ return cells
566
+
567
+ if columns:
568
+ eff = min(cols, _COLUMNS_MAX_WIDTH)
569
+ rows_cells = [_cells_of(i) for i in range(len(items)) if items[i].cells]
570
+ vis_cells = [_cells_of(i) for i in range(viewport, min(viewport + vis, len(items)))
571
+ if items[i].cells]
572
+ col_widths = _table_widths(rows_cells, columns, eff,
573
+ pointer_w=6 if multi else 4, right_margin=_EDGE_MARGIN,
574
+ visible_cells=vis_cells)
575
+
576
+ for i in range(viewport, min(viewport + vis, n)):
577
+ if columns and items[i].cells:
578
+ out.append(_render_table_row(
579
+ _cells_of(i), columns, i == cursor, col_widths, eff, _EDGE_MARGIN,
580
+ is_checked=items[i].checked if multi else None,
581
+ disabled=items[i].disabled))
582
+ _row_plain[i] = _plain(out[-1])
583
+ continue
584
+
585
+ _ct = items[i].cursor_title
586
+ label = str(_ct if (_ct is not None and i == cursor) else items[i].title)
587
+ if multi:
588
+ max_w = cols - 9
589
+ else:
590
+ max_w = cols - 6
591
+ if len(label) > max_w:
592
+ label = label[:max_w - 1] + "…"
593
+ if multi:
594
+ if items[i].disabled and not items[i].checked:
595
+ # Dimmed (interlocked), not selectable
596
+ out.append(f" {C.DIM}• {label}{C.RESET}")
597
+ elif i == cursor:
598
+ glyph = f"{C.GREEN}✔{C.RESET}" if items[i].checked else f"{C.DIM}•{C.RESET}"
599
+ out.append(f" {C.ACCENT}›{C.RESET} {glyph} {C.PRIMARY}{C.BOLD}{label}{C.RESET}")
600
+ else:
601
+ glyph = f"{C.GREEN}✔{C.RESET}" if items[i].checked else f"{C.DIM}•{C.RESET}"
602
+ out.append(f" {glyph} {C.DIM}{label}{C.RESET}")
603
+ elif items[i].disabled:
604
+ # Section heading / separator: dim, no pointer, slightly outdented.
605
+ out.append(f" {C.DIM}{C.BOLD}{label}{C.RESET}" if label else "")
606
+ elif i == cursor:
607
+ out.append(f" {C.ACCENT}›{C.RESET} {C.PRIMARY}{C.BOLD}{label}{C.RESET}")
608
+ else:
609
+ out.append(f" {C.DIM}{label}{C.RESET}")
610
+ _row_plain[i] = _plain(out[-1])
611
+
612
+ remaining = n - viewport - vis
613
+ out.append(f" {C.DIM}╷ {remaining} below{C.RESET}" if remaining > 0 else "")
614
+ # Inset the hint block by the left margin so it never hugs an edge; _hint
615
+ # centres within _cols() (= width-2*MARGIN_H), so this makes it symmetric.
616
+ # Pin the hint bar to the bottom (just above the miniplayer + status) so
617
+ # its keys keep a fixed screen position across redraws / list sizes.
618
+ append_chrome(out, hints_now, _hint_cells, extra=layout_constraint, help_key=_help_key_free())
619
+ # Hard guarantee: no rendered line ever exceeds the terminal width, so
620
+ # the list can never wrap no matter how narrow the window is.
621
+ _w = ui.get_terminal_width() # once per frame, not per line
622
+ return [_clip_ansi(line, _w) for line in out]
623
+
624
+ result = None
625
+ _sel_last_click: int | None = None
626
+ try:
627
+ _set_raw(fd)
628
+ enable_mouse()
629
+ screen_takeover_next() # paint over the previous screen, no flash
630
+ w.render(_lines())
631
+
632
+ while True:
633
+ if ui.consume_resize():
634
+ ui.clear_screen()
635
+ w.anchor_reset()
636
+ w.render(_lines())
637
+ continue
638
+
639
+ if not _wait_for_keypress(0.05):
640
+ continue
641
+
642
+ key = _read_key(fd)
643
+ # Transport keys, clicks on the now-playing box, and clicks on our own
644
+ # hint glyphs are all handled once, here, before the switch below:
645
+ # box → transport/open, hint → replay its key.
646
+ _ch = consume_chrome(key, _hint_cells)
647
+ if _ch is CHROME_HANDLED:
648
+ continue
649
+ if _ch is CHROME_REDRAW:
650
+ _sel_last_click = None; w.anchor_reset(); w.render(_lines()); continue
651
+ if _ch is not None:
652
+ key = _ch # replay the hint's key through the switch
653
+ if _edit_on:
654
+ # Editing owns every key: nothing here may fall through to the
655
+ # list's own navigation, and while the text field is live that
656
+ # includes 'q' and the cycle key (an artist name may contain
657
+ # either). ↑↓ step the cycle, which is also the way back out of
658
+ # the field and on to the next option.
659
+ if key == 'ESC':
660
+ _edit_on = False
661
+ elif key == 'ENTER':
662
+ chosen = ("".join(_edit_buf) if _editing_text()
663
+ else _edit_opts[_edit_i]).strip()
664
+ if chosen and row_edit_commit is not None:
665
+ row_edit_commit(items[cursor].value, chosen)
666
+ _edit_on = False
667
+ elif key in ('UP', 'DOWN') or (key in row_edit_keys and not _editing_text()):
668
+ step = -1 if key == 'UP' else 1
669
+ was = (_edit_opts[_edit_i] if not _editing_text()
670
+ else "".join(_edit_buf))
671
+ _edit_i = (_edit_i + step) % (len(_edit_opts) + 1)
672
+ if _editing_text():
673
+ # Seed the field from wherever the cycle left off, so it
674
+ # opens on something to amend rather than empty.
675
+ _edit_buf = list(was)
676
+ _edit_pos = len(_edit_buf)
677
+ elif _editing_text() and (new_pos := edit_line(_edit_buf, _edit_pos, key)) is not None:
678
+ _edit_pos = new_pos
679
+ _sel_last_click = None
680
+ w.render(_lines())
681
+ continue
682
+
683
+ act = keys.action(key, "list")
684
+ if act in ("list.row_options", "list.list_options"):
685
+ # Everything for the row (o) or the whole list (O): picking
686
+ # an entry does what its key does, from here on.
687
+ entries = _row_menu() if act == "list.row_options" else _list_menu()
688
+ title = (str(items[cursor].title) if act == "list.row_options" else message) or "This list"
689
+ picked = options_menu(title, entries) if entries else None
690
+ enable_mouse()
691
+ sys.stdout.flush()
692
+ _sel_last_click = None
693
+ w.anchor_reset()
694
+ if picked is None:
695
+ w.render(_lines()); continue
696
+ kind, spec = picked
697
+ if kind == "choose":
698
+ result = items[cursor].value; break
699
+ if kind == "list":
700
+ _ret = list_actions[spec]()
701
+ if _ret is not None:
702
+ result = _ret; break
703
+ w.render(_lines()); continue
704
+ key, act = spec, None # as if its key were pressed
705
+ if key == 'CTRL_C': break
706
+ elif key in row_edit_keys and row_edit is not None and not items[cursor].disabled:
707
+ # Open the cycle on the row's current value; a second press steps
708
+ # to the next option (see the edit block above).
709
+ _edit_opts = [str(o) for o in (row_edit(items[cursor].value) or []) if str(o)]
710
+ _edit_i = 0
711
+ _edit_buf = list(_edit_opts[0]) if _edit_opts else []
712
+ _edit_pos = len(_edit_buf)
713
+ _edit_on = True
714
+ _sel_last_click = None
715
+ w.render(_lines())
716
+ elif act == 'list.up': cursor = _step(cursor, -1); _sel_last_click = None; w.render(_lines())
717
+ elif act == 'list.down': cursor = _step(cursor, 1); _sel_last_click = None; w.render(_lines())
718
+ elif act == 'list.top': cursor = selectable[0]; _sel_last_click = None; w.render(_lines())
719
+ elif act == 'list.bottom': cursor = selectable[-1]; _sel_last_click = None; w.render(_lines())
720
+ elif act == 'list.page_up': cursor = _nearest_selectable(max(0, cursor - _visible_rows())); _sel_last_click = None; w.render(_lines())
721
+ elif act == 'list.page_down': cursor = _nearest_selectable(min(len(items) - 1, cursor + _visible_rows())); _sel_last_click = None; w.render(_lines())
722
+ elif act == 'list.toggle' and multi:
723
+ it = items[cursor]
724
+ if not it.disabled or it.checked:
725
+ if interlock_category_callback and _locked_category[0] and not it.checked:
726
+ cat = interlock_category_callback(it.value)
727
+ if cat != _locked_category[0]:
728
+ sys.stdout.write("\a"); sys.stdout.flush(); continue
729
+ it.checked = not it.checked
730
+ _update_interlock()
731
+ selectable[:] = [i for i, x in enumerate(items) if not x.disabled or x.checked]
732
+ w.render(_lines())
733
+ elif act == 'list.toggle_all' and _toggle_all_ok:
734
+ # Toggle every selectable row at once: check all, or clear all if
735
+ # everything is already checked.
736
+ targets = [it for it in items if not it.disabled]
737
+ make_checked = any(not it.checked for it in targets)
738
+ for it in targets:
739
+ it.checked = make_checked
740
+ selectable[:] = [i for i, x in enumerate(items) if not x.disabled or x.checked]
741
+ _sel_last_click = None
742
+ w.render(_lines())
743
+ elif act == 'list.choose':
744
+ if multi:
745
+ result = [it.value for it in items if it.checked]; break
746
+ elif not items[cursor].disabled:
747
+ result = items[cursor].value; break
748
+ elif act == 'list.back':
749
+ if allow_back:
750
+ result = None; break
751
+ # Top-level menu: no back/cancel, only forward or quit.
752
+ elif act == 'list.quit': raise QuitToTerminal()
753
+ elif on_inspect is not None and key in inspect_keys and not items[cursor].disabled:
754
+ # Inspect the current row (e.g. a full detail view) without
755
+ # ending selection or losing checkbox state. The callback runs
756
+ # its own full-screen prompt, so re-arm mouse reporting and force
757
+ # a full redraw when it returns.
758
+ _ret = on_inspect(items[cursor].value)
759
+ if _ret is not None:
760
+ result = _ret; break
761
+ enable_mouse()
762
+ sys.stdout.flush()
763
+ _sel_last_click = None
764
+ w.anchor_reset()
765
+ w.render(_lines())
766
+ elif list_actions and key in list_actions:
767
+ _ret = list_actions[key]()
768
+ if _ret is not None:
769
+ result = _ret; break
770
+ enable_mouse()
771
+ sys.stdout.flush()
772
+ w.anchor_reset()
773
+ w.render(_lines())
774
+ elif (row_actions and key in row_actions and not items[cursor].disabled
775
+ and (row_action_applies is None
776
+ or row_action_applies(next((s for s in _row_specs if key in keys.keys_for(s)), key),
777
+ items[cursor].value))):
778
+ # Act on the highlighted row (e.g. queue this track) and stay in
779
+ # the list; the callback shows its own status; we just redraw.
780
+ _ret = row_actions[key](items[cursor].value)
781
+ if _ret is not None:
782
+ result = _ret; break
783
+ # The callback may have opened its own screen (a menu): take the
784
+ # mouse back and repaint in full, as after on_inspect.
785
+ enable_mouse()
786
+ sys.stdout.flush()
787
+ _sel_last_click = None
788
+ w.anchor_reset()
789
+ w.render(_lines())
790
+ elif (on_move is not None and keys.action(key, "list") in ("list.move_up", "list.move_down")
791
+ and not items[cursor].disabled):
792
+ delta = -1 if keys.pressed(key, "list.move_up") else 1
793
+ j = cursor + delta
794
+ if 0 <= j < len(items) and not items[j].disabled and on_move(items[cursor].value, delta):
795
+ items[cursor], items[j] = items[j], items[cursor]
796
+ cursor = j
797
+ _sel_last_click = None
798
+ w.render(_lines())
799
+ elif shortcuts and key in shortcuts: result = shortcuts[key]; break
800
+ elif key == 'SCROLL_UP': cursor = _step(cursor, -1); _sel_last_click = None; w.render(_lines())
801
+ elif key == 'SCROLL_DOWN': cursor = _step(cursor, 1); _sel_last_click = None; w.render(_lines())
802
+ elif key.startswith('MOUSE_CLICK:'):
803
+ parts = key.split(':')
804
+ r, col = int(parts[2]), int(parts[3]) if len(parts) > 3 else 1
805
+ # Click on the status-bar row's pulsing ● beacon → open the
806
+ # activity centre (only while something is actually running).
807
+ if (chrome._activity_opener is not None
808
+ and r >= ui.get_terminal_height()
809
+ and ui.has_background_tasks()):
810
+ chrome._activity_opener()
811
+ enable_mouse()
812
+ sys.stdout.flush()
813
+ _sel_last_click = None
814
+ w.anchor_reset()
815
+ w.render(_lines())
816
+ continue
817
+ if w.row is None:
818
+ continue
819
+ # render() prepends MARGIN_V blank rows before lines[0].
820
+ # lines[] layout: H header lines, message, viewport-above
821
+ # indicator, then items. So item[viewport] is at:
822
+ # terminal row = w.row + MARGIN_V + H + 2
823
+ i = r - w.row - ui.MARGIN_V - _last_hlen[0] - 2
824
+ idx = viewport + i
825
+ if not (0 <= idx < len(items)):
826
+ continue
827
+ clickable = not items[idx].disabled
828
+
829
+ # A click only confirms/toggles when it lands on a printed
830
+ # character; clicking the blank space anywhere in a row (trailing
831
+ # padding, gaps between table columns, the empty left margin) just
832
+ # moves the highlight; it never enters.
833
+ row_plain = _row_plain.get(idx, "")
834
+ on_char = 0 < col <= len(row_plain) and row_plain[col - 1] != ' '
835
+ if not on_char:
836
+ if clickable or (multi and items[idx].checked):
837
+ cursor = idx
838
+ _sel_last_click = None
839
+ w.render(_lines())
840
+ continue
841
+
842
+ if multi and (clickable or items[idx].checked):
843
+ cursor = idx
844
+ it = items[cursor]
845
+ if not (interlock_category_callback and _locked_category[0]
846
+ and not it.checked
847
+ and interlock_category_callback(it.value) != _locked_category[0]):
848
+ it.checked = not it.checked
849
+ _update_interlock()
850
+ selectable[:] = [i for i, x in enumerate(items) if not x.disabled or x.checked]
851
+ w.render(_lines())
852
+ elif not multi and clickable:
853
+ if idx == cursor or _sel_last_click == idx:
854
+ # Already on this item (keyboard or prior click): confirm
855
+ cursor = idx
856
+ result = items[cursor].value
857
+ break
858
+ else:
859
+ _sel_last_click = idx
860
+ cursor = idx
861
+ w.render(_lines())
862
+ elif not multi:
863
+ # Disabled/heading row: move cursor, reset click state
864
+ _sel_last_click = None
865
+ cursor = idx
866
+ w.render(_lines())
867
+
868
+ finally:
869
+ disable_mouse()
870
+ _restore_term_attrs(fd, old)
871
+ w.clear()
872
+ if place is not None and items:
873
+ place.value, place.pos = items[cursor].value, cursor
874
+
875
+ return result
876
+
877
+
878
+ def live_select(message: str, provider: Callable[[str], list], *,
879
+ count_of: Callable[[], int] | None = None,
880
+ header: list | None | Callable[[], list[str]] = None,
881
+ columns: list | None = None,
882
+ extra_hints: dict[str, str] | None = None,
883
+ on_cycle: Callable[[int], None] | None = None,
884
+ cycle_key: str | None = None,
885
+ section_nav: bool = False,
886
+ row_actions: dict[str, Callable[[Any], None]] | None = None,
887
+ placeholder: str = "type to search…",
888
+ initial_query: str = "") -> Any:
889
+ """Incremental "search box + live results" widget.
890
+
891
+ `provider(query)` is called on each query change and returns the ranked list
892
+ of Choice to display (cells already built, including any highlight segments).
893
+ Letters/digits type into the query; ← → move the query caret; ↑ ↓ (and the
894
+ scroll wheel) move through results; Enter selects the highlighted row; Esc
895
+ cancels. Returns the chosen Choice.value, or None.
896
+
897
+ `row_actions`: key -> callback(current row value), same shape as
898
+ `select`'s: the only per-row hotkey mechanism available here, since every
899
+ other key types into the query. Bind non-printable keys only (e.g. a
900
+ Ctrl-combo); the callback runs and the list stays open, redrawing after.
901
+
902
+ `count_of()` gives the number shown as "N results" when the list holds
903
+ more than the matches (e.g. section headings); by default it is len(list).
904
+
905
+ `on_cycle(step)` is called when `cycle_key` is pressed (e.g. to change the
906
+ search scope), then the results are recomputed.
907
+
908
+ `section_nav` makes Tab / Shift-Tab jump between section headings, and dims
909
+ the rows outside the section under the cursor.
910
+
911
+ `initial_query` pre-fills the query, caret at its end.
912
+
913
+ `placeholder` is greyed out inside the empty field, behind the caret, and
914
+ goes as soon as there is a query to show in its place.
915
+ """
916
+ fd = sys.stdin.fileno()
917
+ old = _get_term_attrs(fd)
918
+ w = _Widget(fd)
919
+
920
+ query: list[str] = list(initial_query)
921
+ qpos = len(query)
922
+ items: list = list(provider("".join(query)))
923
+ cursor = 0
924
+ viewport = 0
925
+ _sel_last_click: int | None = None
926
+ # Maps a visible item index → its ANSI-stripped rendered text, so a mouse
927
+ # click can tell whether it landed on a printed character or blank space
928
+ # (shared hit-test convention with `select`).
929
+ _row_plain: dict[int, str] = {}
930
+ _fixed_rows = [0] # header + query + count + above-indicator lines, this frame
931
+
932
+ base_hints = {L("search.up", "search.down"): "results", L("search.back"): "back",
933
+ L("search.choose"): "confirm"}
934
+ if section_nav:
935
+ base_hints[L("search.next_section", first=True)] = "section"
936
+ # Keys given as action ids (backbone.keys), as select() takes them.
937
+ row_actions = keys.expand(row_actions)
938
+ cycle_keys = keys.keys_for(cycle_key) if cycle_key is not None else ()
939
+ hints = {**{keys.hint_for(k): v for k, v in (extra_hints or {}).items()}, **base_hints}
940
+ # Maps an absolute (row, col) on a hint line → the key clicking it replays.
941
+ _hint_cells: dict[tuple[int, int], str] = {}
942
+
943
+ def _header_lines() -> list[str]:
944
+ if header is None:
945
+ return []
946
+ return header() if callable(header) else list(header)
947
+
948
+ def _selectable() -> list[int]:
949
+ return [i for i, it in enumerate(items) if not it.disabled]
950
+
951
+ def _step(cur: int, direction: int) -> int:
952
+ sel = _selectable()
953
+ if not sel:
954
+ return cur
955
+ if cur in sel:
956
+ idx = sel.index(cur)
957
+ return sel[(idx + direction) % len(sel)]
958
+ return sel[0] if direction > 0 else sel[-1]
959
+
960
+ def _headings() -> list[int]:
961
+ """Indices of the section heading rows (disabled rows carrying a title)."""
962
+ return [i for i, it in enumerate(items) if it.disabled and it.title]
963
+
964
+ def _owners() -> list[int]:
965
+ """Per row, the index of the heading that owns it (-1 above the first).
966
+
967
+ Built in one pass and reused for the whole frame; resolving each row
968
+ against the heading list separately is quadratic, and this runs on every
969
+ keystroke of a live search.
970
+ """
971
+ out: list[int] = []
972
+ cur = -1
973
+ for i, it in enumerate(items):
974
+ if it.disabled and it.title:
975
+ cur = i
976
+ out.append(cur)
977
+ return out
978
+
979
+ def _section_of(idx: int) -> int:
980
+ """Index of the heading that owns row `idx`, or -1 above the first one."""
981
+ owners = _owners()
982
+ return owners[idx] if 0 <= idx < len(owners) else -1
983
+
984
+ def _jump_section(direction: int) -> int:
985
+ """First selectable row of the next/previous section, wrapping around."""
986
+ heads = _headings()
987
+ if not heads:
988
+ return cursor
989
+ here = _section_of(cursor)
990
+ order = [-1] + heads if _section_of(0) == -1 and heads[0] > 0 else heads
991
+ try:
992
+ pos = order.index(here)
993
+ except ValueError:
994
+ pos = 0
995
+ target = order[(pos + direction) % len(order)]
996
+ start = 0 if target == -1 else target + 1
997
+ for i in range(start, len(items)):
998
+ if items[i].disabled:
999
+ if i in heads and i != target:
1000
+ break
1001
+ continue
1002
+ return i
1003
+ return cursor
1004
+
1005
+ def _recompute() -> None:
1006
+ """Re-run the provider for the current query and reset cursor/viewport onto the new results."""
1007
+ nonlocal items, cursor, viewport, _sel_last_click
1008
+ # Called for the empty query too: the provider owns what a query yields,
1009
+ # including "nothing", and anything it reported for the previous one
1010
+ # (result counts, section tallies) has to be cleared rather than left
1011
+ # standing over an empty box.
1012
+ try:
1013
+ items = list(provider("".join(query)))
1014
+ except Exception:
1015
+ items = []
1016
+ cursor = _step(-1, 1) if items else 0
1017
+ viewport = 0
1018
+ _sel_last_click = None
1019
+
1020
+ def _lines() -> list:
1021
+ nonlocal viewport
1022
+ width = ui.get_terminal_width()
1023
+ cols = _cols()
1024
+ ui.footer_lines(width) # refresh box height (see select._lines)
1025
+ out = _header_lines()
1026
+
1027
+ qtext = "".join(query)
1028
+ # An empty message means the header already names the screen, so the query
1029
+ # then starts at the normal margin rather than behind a stray space.
1030
+ _label = f"{C.DIM}{message}{C.RESET} " if message else ""
1031
+ # A block cursor sitting on the character, not a bar drawn between two:
1032
+ # the query stays still as the caret walks it. Empty, the block sits on
1033
+ # the placeholder's first letter (where typing will start) with the
1034
+ # rest of the hint dimmed behind it.
1035
+ if qtext:
1036
+ _field = block_cursor(qtext, qpos)
1037
+ elif placeholder:
1038
+ _field = block_cursor(placeholder, 0, base=C.DIM) + C.RESET
1039
+ else:
1040
+ _field = block_cursor("", 0)
1041
+ out.append(f" {_label}{_field}")
1042
+ count = ("" if not qtext else
1043
+ ui.plural(len(items) if count_of is None else count_of(), "result"))
1044
+ out.append(f" {C.DIM}{count}{C.RESET}" if count else "")
1045
+
1046
+ hint_lines = chrome_hint_lines(hints)
1047
+ # out already holds header + message + count; +2 for the above/below rows.
1048
+ overhead = len(out) + len(hint_lines) + 2
1049
+ vis = max(2, _visible_rows() - overhead)
1050
+
1051
+ n = len(items)
1052
+ viewport = _scroll(viewport, cursor, vis, items)
1053
+ out.append(f" {C.DIM}╵ {viewport} above{C.RESET}" if viewport > 0 else "")
1054
+ _fixed_rows[0] = len(out) # rows before the first item: the click-math offset
1055
+ _row_plain.clear()
1056
+
1057
+ eff = min(cols, _COLUMNS_MAX_WIDTH)
1058
+ col_widths: list = []
1059
+ if columns:
1060
+ rows_cells = [it.cells for it in items if it.cells]
1061
+ if rows_cells:
1062
+ vis_cells = [it.cells for it in items[viewport:viewport + vis] if it.cells]
1063
+ col_widths = _table_widths(rows_cells, columns, eff,
1064
+ pointer_w=4, right_margin=_EDGE_MARGIN,
1065
+ visible_cells=vis_cells)
1066
+
1067
+ owners = _owners() if section_nav else []
1068
+ focus = (owners[cursor] if section_nav and 0 <= cursor < len(owners) else None)
1069
+ for i in range(viewport, min(viewport + vis, n)):
1070
+ it = items[i]
1071
+ if columns and it.cells:
1072
+ out.append(_render_table_row(it.cells, columns, i == cursor,
1073
+ col_widths, eff, _EDGE_MARGIN,
1074
+ dim=section_nav and owners[i] != focus))
1075
+ elif it.disabled:
1076
+ out.append(f" {C.DIM}{C.BOLD}{it.title}{C.RESET}" if it.title else "")
1077
+ elif i == cursor:
1078
+ out.append(f" {C.ACCENT}›{C.RESET} {C.PRIMARY}{C.BOLD}{it.title}{C.RESET}")
1079
+ else:
1080
+ out.append(f" {C.DIM}{it.title}{C.RESET}")
1081
+ _row_plain[i] = _plain(out[-1])
1082
+
1083
+ remaining = n - viewport - vis
1084
+ out.append(f" {C.DIM}╷ {remaining} below{C.RESET}" if remaining > 0 else "")
1085
+ # Inset the hint block by the left margin so it never hugs an edge; _hint
1086
+ # centres within _cols() (= width-2*MARGIN_H), so this makes it symmetric.
1087
+ _filler = _hint_pin_target() - len(out) - len(hint_lines)
1088
+ if _filler > 0:
1089
+ out.extend([""] * _filler)
1090
+ append_chrome(out, hints, _hint_cells, pin=False)
1091
+ return [_clip_ansi(line, width) for line in out]
1092
+
1093
+ result = None
1094
+ try:
1095
+ _set_raw(fd)
1096
+ enable_mouse()
1097
+ screen_takeover_next() # paint over the previous screen, no flash
1098
+ w.render(_lines())
1099
+
1100
+ while True:
1101
+ if ui.consume_resize():
1102
+ ui.clear_screen()
1103
+ w.anchor_reset()
1104
+ w.render(_lines())
1105
+ continue
1106
+ if not _wait_for_keypress(0.05):
1107
+ continue
1108
+ key = _read_key(fd)
1109
+
1110
+ # Transport keys, clicks on the now-playing box, and clicks on our own
1111
+ # hint glyphs, handled once here, before the switch below.
1112
+ _ch = consume_chrome(key, _hint_cells)
1113
+ if _ch is CHROME_HANDLED:
1114
+ continue
1115
+ if _ch is CHROME_REDRAW:
1116
+ w.anchor_reset(); w.render(_lines()); continue
1117
+ if _ch is not None:
1118
+ key = _ch # replay the hint's key through the switch
1119
+
1120
+ act = keys.action(key, "search")
1121
+ if key == 'CTRL_C':
1122
+ raise QuitToTerminal()
1123
+ elif act == 'search.back':
1124
+ result = None
1125
+ break
1126
+ elif row_actions and isinstance(key, str) and key in row_actions and items:
1127
+ row_actions[key](items[cursor].value)
1128
+ enable_mouse() # it may have opened its own screen
1129
+ sys.stdout.flush()
1130
+ w.anchor_reset()
1131
+ w.render(_lines())
1132
+ elif act in ('search.next_section', 'search.prev_section') and section_nav:
1133
+ cursor = _jump_section(-1 if act == 'search.prev_section' else 1)
1134
+ w.render(_lines())
1135
+ elif key in cycle_keys and on_cycle is not None:
1136
+ on_cycle(1) # on_cycle(step): step through the scopes
1137
+ _recompute()
1138
+ w.render(_lines())
1139
+ elif act == 'search.choose':
1140
+ if items and not items[cursor].disabled:
1141
+ result = items[cursor].value
1142
+ break
1143
+ elif act == 'search.up':
1144
+ cursor = _step(cursor, -1); _sel_last_click = None; w.render(_lines())
1145
+ elif act == 'search.down':
1146
+ cursor = _step(cursor, 1); _sel_last_click = None; w.render(_lines())
1147
+ elif key == 'SCROLL_UP':
1148
+ cursor = _step(cursor, -1); _sel_last_click = None; w.render(_lines())
1149
+ elif key == 'SCROLL_DOWN':
1150
+ cursor = _step(cursor, 1); _sel_last_click = None; w.render(_lines())
1151
+ elif key.startswith('MOUSE_CLICK:') and w.row is not None:
1152
+ # Same two-click convention as `select`: a click on a row not
1153
+ # already highlighted moves the cursor there; clicking it again
1154
+ # (or a row already under the cursor) confirms, so one click can't
1155
+ # accidentally jump straight into a result.
1156
+ parts = key.split(':')
1157
+ r = int(parts[2]) if len(parts) > 2 else 0
1158
+ col = int(parts[3]) if len(parts) > 3 else 1
1159
+ i = r - w.row - ui.MARGIN_V - _fixed_rows[0]
1160
+ idx = viewport + i
1161
+ if 0 <= idx < len(items):
1162
+ clickable = not items[idx].disabled
1163
+ row_plain = _row_plain.get(idx, "")
1164
+ on_char = 0 < col <= len(row_plain) and row_plain[col - 1] != ' '
1165
+ if not on_char:
1166
+ if clickable:
1167
+ cursor = idx
1168
+ _sel_last_click = None
1169
+ w.render(_lines())
1170
+ elif clickable:
1171
+ if idx == cursor or _sel_last_click == idx:
1172
+ cursor = idx
1173
+ result = items[cursor].value
1174
+ break
1175
+ _sel_last_click = idx
1176
+ cursor = idx
1177
+ w.render(_lines())
1178
+ else:
1179
+ _sel_last_click = None
1180
+ cursor = idx
1181
+ w.render(_lines())
1182
+ elif act == 'search.page_up':
1183
+ sel = _selectable()
1184
+ if sel:
1185
+ cursor = max(sel[0], cursor - 5)
1186
+ if items[cursor].disabled:
1187
+ cursor = _step(cursor, -1)
1188
+ _sel_last_click = None
1189
+ w.render(_lines())
1190
+ elif act == 'search.page_down':
1191
+ sel = _selectable()
1192
+ if sel:
1193
+ cursor = min(sel[-1], cursor + 5)
1194
+ if items[cursor].disabled:
1195
+ cursor = _step(cursor, 1)
1196
+ _sel_last_click = None
1197
+ w.render(_lines())
1198
+ elif key == 'LEFT':
1199
+ qpos = max(0, qpos - 1); w.render(_lines())
1200
+ elif key == 'RIGHT':
1201
+ qpos = min(len(query), qpos + 1); w.render(_lines())
1202
+ elif key == 'HOME':
1203
+ qpos = 0; w.render(_lines())
1204
+ elif key == 'END':
1205
+ qpos = len(query); w.render(_lines())
1206
+ elif key == 'BACKSPACE':
1207
+ if qpos > 0:
1208
+ query.pop(qpos - 1); qpos -= 1
1209
+ _recompute(); w.render(_lines())
1210
+ elif key == 'SPACE':
1211
+ query.insert(qpos, ' '); qpos += 1
1212
+ _recompute(); w.render(_lines())
1213
+ elif len(key) == 1 and key.isprintable():
1214
+ query.insert(qpos, key); qpos += 1
1215
+ _recompute(); w.render(_lines())
1216
+ finally:
1217
+ disable_mouse()
1218
+ _restore_term_attrs(fd, old)
1219
+ w.clear()
1220
+
1221
+ return result
1222
+
1223
+
1224
+ def confirm(message: str, default: bool = False) -> bool:
1225
+ """Yes/no prompt; y/n or Enter (accepting `default`) answers, Ctrl-C answers no.
1226
+ The y / n / ↵ hints are clickable."""
1227
+ fd = sys.stdin.fileno()
1228
+ old = _get_term_attrs(fd)
1229
+ w = _Widget(fd)
1230
+ result = default
1231
+ _hint_cells: dict[tuple[int, int], str] = {}
1232
+
1233
+ def _render():
1234
+ dflt = "yes" if default else "no"
1235
+ pairs = [(L("confirm.yes"), "yes"), (L("confirm.no"), "no"),
1236
+ (L("confirm.default"), f"default ({dflt})"), (L("confirm.back"), "back")]
1237
+ head = [
1238
+ f" {C.DIM}{message}{C.RESET}",
1239
+ f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}",
1240
+ ]
1241
+ lines = list(head)
1242
+ append_chrome(lines, pairs, _hint_cells, help_key=True)
1243
+ w.render(lines)
1244
+
1245
+ try:
1246
+ _set_raw(fd)
1247
+ enable_mouse()
1248
+ _render()
1249
+ while True:
1250
+ if not _wait_for_keypress(0.05):
1251
+ continue
1252
+ key = _read_key(fd)
1253
+ _ch = consume_chrome(key, _hint_cells)
1254
+ if _ch is CHROME_HANDLED:
1255
+ continue
1256
+ if _ch is CHROME_REDRAW:
1257
+ w.anchor_reset(); _render(); continue
1258
+ if _ch is not None:
1259
+ key = _ch
1260
+ if key.startswith('MOUSE_CLICK:'):
1261
+ _mp = key.split(':')
1262
+ _mr = int(_mp[2]); _mc = int(_mp[3]) if len(_mp) > 3 else 1
1263
+ _hk = _hint_cells.get((_mr, _mc))
1264
+ if _hk is None:
1265
+ continue # modal: ignore clicks off the y/n/↵ hints
1266
+ key = _hk # replay the hint's key
1267
+ act = keys.action(key, "confirm")
1268
+ if key == 'CTRL_C': result = False; break
1269
+ # Esc backs out of every other screen, so it must do something here
1270
+ # too: cancelling a yes/no question means "no".
1271
+ elif act == 'confirm.back': result = False; break
1272
+ elif act == 'confirm.default': result = default; break
1273
+ elif act == 'confirm.yes': result = True; break
1274
+ elif act == 'confirm.no': result = False; break
1275
+ finally:
1276
+ disable_mouse()
1277
+ _restore_term_attrs(fd, old)
1278
+ w.clear()
1279
+
1280
+ return result