backpack-backbone 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backbone/__init__.py +30 -0
- backbone/app.py +26 -0
- backbone/datetime_parse.py +224 -0
- backbone/deps.py +98 -0
- backbone/files.py +61 -0
- backbone/keyboard.py +148 -0
- backbone/keys.py +271 -0
- backbone/log.py +61 -0
- backbone/nav.py +17 -0
- backbone/notify.py +36 -0
- backbone/numbering.py +182 -0
- backbone/output.py +216 -0
- backbone/procs.py +60 -0
- backbone/prompt/__init__.py +20 -0
- backbone/prompt/audio.py +496 -0
- backbone/prompt/chrome.py +238 -0
- backbone/prompt/core.py +1562 -0
- backbone/prompt/dates.py +555 -0
- backbone/prompt/keymap.py +143 -0
- backbone/prompt/list_edit.py +974 -0
- backbone/prompt/lists.py +1280 -0
- backbone/prompt/text.py +356 -0
- backbone/prompt/timezone.py +1543 -0
- backbone/prompt/values.py +581 -0
- backbone/terminal_input.py +60 -0
- backbone/timefmt.py +34 -0
- backbone/ui.py +1044 -0
- backpack_backbone-0.2.0.dist-info/METADATA +177 -0
- backpack_backbone-0.2.0.dist-info/RECORD +32 -0
- backpack_backbone-0.2.0.dist-info/WHEEL +5 -0
- backpack_backbone-0.2.0.dist-info/licenses/LICENSE +21 -0
- backpack_backbone-0.2.0.dist-info/top_level.txt +1 -0
backbone/prompt/lists.py
ADDED
|
@@ -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
|