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
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
"""list_edit: the multi-column row editor (people, timestamps, pasted or imported rows)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import os
|
|
6
|
+
from backbone.prompt.core import (
|
|
7
|
+
_get_term_attrs, _set_raw, _restore_term_attrs, _wait_for_keypress, _hint, block_cursor,
|
|
8
|
+
block_cursor_width, _read_key, _visible_rows, _cols, _Widget, add_hint_click_cells,
|
|
9
|
+
footer_click_action, _hint_pin_target, screen_takeover_next, add_help_corner,
|
|
10
|
+
)
|
|
11
|
+
from backbone import keys, ui
|
|
12
|
+
from backbone import datetime_parse as dtp
|
|
13
|
+
from backbone.nav import QuitToTerminal
|
|
14
|
+
from backbone.prompt import chrome
|
|
15
|
+
from backbone.prompt.chrome import (
|
|
16
|
+
CHROME_HANDLED, chrome_hint_pairs, CHROME_REDRAW, consume_chrome, disable_mouse, enable_mouse, MODE_TOGGLE, move_hint,
|
|
17
|
+
)
|
|
18
|
+
from backbone.prompt.lists import confirm
|
|
19
|
+
from backbone.prompt.text import path, system_editor_edit
|
|
20
|
+
from backbone.prompt.core import C
|
|
21
|
+
from backbone.prompt.core import edit_line
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Timestamp cells: a split date/time field inside a list_edit table.
|
|
26
|
+
#
|
|
27
|
+
# The cell is a fixed mask. Every slot that is still a placeholder letter shows
|
|
28
|
+
# dim, so the shape of what you are filling in is always on screen and only the
|
|
29
|
+
# digits have to be typed. One caret walks the whole mask and steps over the
|
|
30
|
+
# separators, which is what makes the arrow keys carry from the end of one part
|
|
31
|
+
# straight into the next instead of needing Tab between them.
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
_TS_MASK = "YYYY-MM-DD HH:MM:SS"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_TS_SLOTS = tuple(i for i, ch in enumerate(_TS_MASK) if ch.isalpha())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Where each part starts, as an index into _TS_SLOTS: Y, M, D, h, m, s.
|
|
40
|
+
_TS_PARTS = ((0, 4), (4, 6), (6, 8), (8, 10), (10, 12), (12, 14))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# The table editor's own keys; moving, saving (choose), back and quit are the
|
|
44
|
+
# shared list keys.
|
|
45
|
+
keys.define("table", "Table editors", [
|
|
46
|
+
("add", ("a",), "add a row"),
|
|
47
|
+
("edit", ("e",), "edit the row"),
|
|
48
|
+
("delete", ("d", "BACKSPACE", "DELETE"), "delete the row"),
|
|
49
|
+
("import_text", ("i",), "import rows typed in an editor"),
|
|
50
|
+
("import_file", ("f",), "import rows from a file"),
|
|
51
|
+
], within=("list", "global"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ts_write(buf: list, start_slot: int, digits: str) -> None:
|
|
55
|
+
"""Write `digits` into consecutive mask slots from `start_slot`."""
|
|
56
|
+
for k, ch in enumerate(digits):
|
|
57
|
+
idx = start_slot + k
|
|
58
|
+
if idx < len(_TS_SLOTS):
|
|
59
|
+
buf[_TS_SLOTS[idx]] = ch
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ts_buffer(value: str) -> list:
|
|
63
|
+
"""A mask buffer seeded from an existing cell value (blank where unknown)."""
|
|
64
|
+
buf = list(_TS_MASK)
|
|
65
|
+
parsed = dtp.parse_datetime(value) if value else None
|
|
66
|
+
if parsed and parsed.date:
|
|
67
|
+
d = parsed.date
|
|
68
|
+
if parsed.precision == 'year':
|
|
69
|
+
_ts_write(buf, 0, f"{d.year:04d}")
|
|
70
|
+
elif parsed.precision == 'month':
|
|
71
|
+
_ts_write(buf, 0, f"{d.year:04d}{d.month:02d}")
|
|
72
|
+
else:
|
|
73
|
+
_ts_write(buf, 0, f"{d.year:04d}{d.month:02d}{d.day:02d}")
|
|
74
|
+
if parsed.time:
|
|
75
|
+
_ts_write(buf, 8, parsed.time.replace(':', ''))
|
|
76
|
+
return buf
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ts_digits(buf: list) -> str:
|
|
80
|
+
"""The 14 mask slots as digits, with a space wherever one is still unfilled."""
|
|
81
|
+
return ''.join(buf[i] if buf[i].isdigit() else ' ' for i in _TS_SLOTS)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _ts_value(buf: list) -> str:
|
|
85
|
+
"""Assemble the cell's value at whatever precision has actually been filled.
|
|
86
|
+
|
|
87
|
+
Leaving the time blank yields a plain date, which is a valid timestamp in its
|
|
88
|
+
own right: the field never forces a time it was not given.
|
|
89
|
+
"""
|
|
90
|
+
d = _ts_digits(buf)
|
|
91
|
+
if ' ' in d[0:4]:
|
|
92
|
+
return ''
|
|
93
|
+
if ' ' in d[4:6]:
|
|
94
|
+
return d[0:4]
|
|
95
|
+
if ' ' in d[6:8]:
|
|
96
|
+
return f"{d[0:4]}-{d[4:6]}"
|
|
97
|
+
date = f"{d[0:4]}-{d[4:6]}-{d[6:8]}"
|
|
98
|
+
if ' ' in d[8:12]:
|
|
99
|
+
return date
|
|
100
|
+
if ' ' in d[12:14]:
|
|
101
|
+
return f"{date} {d[8:10]}:{d[10:12]}"
|
|
102
|
+
return f"{date} {d[8:10]}:{d[10:12]}:{d[12:14]}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ts_step(pos: int, delta: int) -> int:
|
|
106
|
+
"""Move the caret one editable slot, hopping the separators between parts."""
|
|
107
|
+
slots = _TS_SLOTS
|
|
108
|
+
if pos in slots:
|
|
109
|
+
k = slots.index(pos)
|
|
110
|
+
else: # sitting on a separator: snap inward
|
|
111
|
+
k = 0 if delta > 0 else len(slots) - 1
|
|
112
|
+
k = max(0, min(len(slots) - 1, k + delta))
|
|
113
|
+
return slots[k]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _render_timestamp_cell(buf: list, pos: int, width: int, active: bool,
|
|
117
|
+
base: str = '') -> str:
|
|
118
|
+
"""Draw a timestamp cell: entered digits bright, unfilled mask slots dim.
|
|
119
|
+
|
|
120
|
+
`base` is the styling the surrounding row is already drawn in (the selected
|
|
121
|
+
row's colour + bold). Every span closes by resetting AND re-asserting it,
|
|
122
|
+
because a bare reset would end the row's own styling too: the dim separator
|
|
123
|
+
after the year would leave the rest of the stamp, and every column after it,
|
|
124
|
+
unstyled. Same rule the lyric renderer follows for markdown emphasis.
|
|
125
|
+
"""
|
|
126
|
+
close = f"{C.RESET}{base}"
|
|
127
|
+
out = []
|
|
128
|
+
for i, ch in enumerate(buf):
|
|
129
|
+
placeholder = not ch.isdigit()
|
|
130
|
+
if active and i == pos:
|
|
131
|
+
out.append(f"{C.INVERT}{C.BOLD}{ch}{close}")
|
|
132
|
+
elif placeholder:
|
|
133
|
+
# Reset before dimming: on the selected row `base` is bold, and
|
|
134
|
+
# bold+dim together is contradictory (terminals disagree on which
|
|
135
|
+
# wins), which left the separators as bold as the digits.
|
|
136
|
+
out.append(f"{C.RESET}{C.DIM}{ch}{close}")
|
|
137
|
+
else:
|
|
138
|
+
out.append(ch)
|
|
139
|
+
text = "".join(out)
|
|
140
|
+
return text + " " * max(0, width - len(buf))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _render_list_edit_cell(text: str, width: int, is_editing: bool, is_active_col: bool,
|
|
144
|
+
edit_buf: list[str], edit_pos: int,
|
|
145
|
+
is_timestamp: bool = False, base: str = '') -> str:
|
|
146
|
+
"""Render one table cell, showing the live edit buffer with a cursor block when this is the active editing column.
|
|
147
|
+
|
|
148
|
+
`base` is the row's own styling, re-asserted after any span this cell closes
|
|
149
|
+
so the rest of the row keeps it (see :func:`_render_timestamp_cell`).
|
|
150
|
+
"""
|
|
151
|
+
if not is_editing or not is_active_col:
|
|
152
|
+
if is_timestamp:
|
|
153
|
+
# Always draw the mask, even for an empty cell: it shows the shape
|
|
154
|
+
# waiting to be filled, and it keeps the cell a fixed width so the
|
|
155
|
+
# columns after it don't slide left on an empty row.
|
|
156
|
+
return _render_timestamp_cell(_ts_buffer(text), -1, width, False, base)
|
|
157
|
+
return ui.truncate_text(text, width)
|
|
158
|
+
|
|
159
|
+
if is_timestamp:
|
|
160
|
+
return _render_timestamp_cell(edit_buf, edit_pos, width, True, base)
|
|
161
|
+
|
|
162
|
+
buf_str = "".join(edit_buf)
|
|
163
|
+
display_str = block_cursor(buf_str, edit_pos, base)
|
|
164
|
+
padding = max(0, width - block_cursor_width(buf_str, edit_pos))
|
|
165
|
+
return display_str + (" " * padding)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _layout_columns(num_cols: int, avail_w: int, col_ratios=None, col_mins=None) -> list:
|
|
169
|
+
"""Split `avail_w` across the columns, honouring per-column minimums.
|
|
170
|
+
|
|
171
|
+
Ratios (or an even split) set the starting widths; any column below its
|
|
172
|
+
minimum is then raised to it and the difference taken back from whichever
|
|
173
|
+
columns have the most room to spare. A minimum is what keeps a fixed-shape
|
|
174
|
+
cell (a full timestamp, say) readable at any terminal width while the
|
|
175
|
+
short columns beside it shrink instead.
|
|
176
|
+
"""
|
|
177
|
+
if col_ratios and len(col_ratios) == num_cols:
|
|
178
|
+
total = sum(col_ratios) or 1
|
|
179
|
+
widths = [max(1, int(avail_w * r / total)) for r in col_ratios]
|
|
180
|
+
widths[-1] = max(1, avail_w - sum(widths[:-1]))
|
|
181
|
+
else:
|
|
182
|
+
even = avail_w // num_cols
|
|
183
|
+
widths = [even] * (num_cols - 1) + [avail_w - even * (num_cols - 1)]
|
|
184
|
+
|
|
185
|
+
if not col_mins:
|
|
186
|
+
return widths
|
|
187
|
+
mins = list(col_mins)[:num_cols] + [0] * max(0, num_cols - len(col_mins))
|
|
188
|
+
mins = [max(0, int(m or 0)) for m in mins]
|
|
189
|
+
|
|
190
|
+
if sum(mins) >= avail_w:
|
|
191
|
+
# Too narrow to satisfy every minimum: share it out in their proportion
|
|
192
|
+
# rather than starving the last column to nothing.
|
|
193
|
+
total = sum(mins) or 1
|
|
194
|
+
shared = [max(1, int(avail_w * m / total)) for m in mins]
|
|
195
|
+
# Hand the truncation remainder to the widest column so the row still
|
|
196
|
+
# uses the full width instead of leaving a ragged gap.
|
|
197
|
+
spare = avail_w - sum(shared)
|
|
198
|
+
if spare > 0:
|
|
199
|
+
shared[max(range(num_cols), key=lambda i: shared[i])] += spare
|
|
200
|
+
return shared
|
|
201
|
+
|
|
202
|
+
deficit = 0
|
|
203
|
+
for i, m in enumerate(mins):
|
|
204
|
+
if widths[i] < m:
|
|
205
|
+
deficit += m - widths[i]
|
|
206
|
+
widths[i] = m
|
|
207
|
+
while deficit > 0:
|
|
208
|
+
slack = [widths[i] - mins[i] for i in range(num_cols)]
|
|
209
|
+
best = max(range(num_cols), key=lambda i: slack[i])
|
|
210
|
+
if slack[best] <= 0:
|
|
211
|
+
break
|
|
212
|
+
take = min(deficit, slack[best])
|
|
213
|
+
widths[best] -= take
|
|
214
|
+
deficit -= take
|
|
215
|
+
|
|
216
|
+
drift = avail_w - sum(widths)
|
|
217
|
+
if drift:
|
|
218
|
+
slack = [widths[i] - mins[i] for i in range(num_cols)]
|
|
219
|
+
target = max(range(num_cols), key=lambda i: slack[i]) if drift < 0 else num_cols - 1
|
|
220
|
+
widths[target] = max(1, widths[target] + drift)
|
|
221
|
+
return widths
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _build_list_edit_lines(
|
|
225
|
+
message: str, items: list, headers: tuple[str, ...],
|
|
226
|
+
cursor: int, viewport: int,
|
|
227
|
+
edit_mode: bool, edit_col: int, edit_buf: list[str], edit_pos: int,
|
|
228
|
+
fixed_rows: bool = False,
|
|
229
|
+
barrel_mode: bool = False, barrel_hints: list[str] | None = None, barrel_idx: int = 0,
|
|
230
|
+
col_ratios: tuple | None = None, col_mins: tuple | None = None,
|
|
231
|
+
col_types: dict | None = None,
|
|
232
|
+
) -> tuple[list[str], int, int, int]:
|
|
233
|
+
"""Lay out the full list_edit screen (header, column-aligned rows or barrel-mode cell, hints)
|
|
234
|
+
and report the resulting viewport/visible-row/header-row counts."""
|
|
235
|
+
num_cols = len(headers)
|
|
236
|
+
cols = _cols()
|
|
237
|
+
ui.footer_lines(ui.get_terminal_width()) # refresh box height (see select._lines)
|
|
238
|
+
c = cols - 4
|
|
239
|
+
inner = c
|
|
240
|
+
out = []
|
|
241
|
+
|
|
242
|
+
L = keys.label
|
|
243
|
+
_imports = {L("table.import_text"): "import text", L("table.import_file"): "from file"}
|
|
244
|
+
_end = {L("list.back", most=1): "back", L("list.choose", most=1): "save", L("list.quit"): "quit app"}
|
|
245
|
+
if fixed_rows:
|
|
246
|
+
base_hints = {L("list.up", "list.down"): "move", L("table.edit"): "edit", **_imports, **_end}
|
|
247
|
+
else:
|
|
248
|
+
base_hints = {L("list.up", "list.down"): "move", L("table.add"): "add", L("table.edit"): "edit",
|
|
249
|
+
L("table.delete", most=1): "delete", move_hint()[0]: move_hint()[1], **_imports, **_end}
|
|
250
|
+
# Both variants answer ^t (see list_edit's key loop), so both advertise it.
|
|
251
|
+
if chrome._value_toggle_enabled:
|
|
252
|
+
base_hints[keys.label("global.raw_text")] = "raw text"
|
|
253
|
+
edit_hints = {"tab/⇧tab": "column", "esc": "back", "↵": "save"}
|
|
254
|
+
|
|
255
|
+
out.append(f" {C.DIM}{message}{C.RESET}")
|
|
256
|
+
out.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
|
|
257
|
+
|
|
258
|
+
avail_w = max(10, inner - 4 - (2 * (num_cols - 1)))
|
|
259
|
+
col_widths = _layout_columns(num_cols, avail_w, col_ratios, col_mins)
|
|
260
|
+
last_w = col_widths[-1]
|
|
261
|
+
|
|
262
|
+
if num_cols > 1:
|
|
263
|
+
h_parts = [f"{headers[i]:<{col_widths[i]}}" for i in range(num_cols - 1)]
|
|
264
|
+
h_parts.append(f"{headers[-1]}")
|
|
265
|
+
out.append(f" {C.DIM}{' '.join(h_parts)}{C.RESET}")
|
|
266
|
+
|
|
267
|
+
u_parts = ["─" * col_widths[i] for i in range(num_cols - 1)]
|
|
268
|
+
# The last column absorbs whatever width is left over, so rule it to what
|
|
269
|
+
# it actually holds; otherwise the underline trails far past the content
|
|
270
|
+
# as a long bar of nothing.
|
|
271
|
+
_last_content = max([len(headers[-1])] + [
|
|
272
|
+
len(str((list(it) if isinstance(it, (list, tuple)) else [it])[num_cols - 1]))
|
|
273
|
+
for it in items
|
|
274
|
+
if len(list(it) if isinstance(it, (list, tuple)) else [it]) >= num_cols] or [0])
|
|
275
|
+
u_parts.append("─" * max(1, min(last_w, _last_content)))
|
|
276
|
+
out.append(f" {' '.join(u_parts)}")
|
|
277
|
+
else:
|
|
278
|
+
out.append(f" {C.DIM}{headers[0]}{C.RESET}")
|
|
279
|
+
out.append(f" {'─' * inner}")
|
|
280
|
+
|
|
281
|
+
if edit_mode and (col_types or {}).get(edit_col) == 'timestamp':
|
|
282
|
+
edit_hints = {"←→": "move part", "tab/⇧tab": "column",
|
|
283
|
+
"esc": "back", "↵": "save"}
|
|
284
|
+
if barrel_mode:
|
|
285
|
+
edit_hints = {"↑↓": "cycle", "↵": "confirm", "esc": "back"}
|
|
286
|
+
# Augment with the transport keys here (not at the call site) so the pairs
|
|
287
|
+
# handed back for the click map match exactly what was drawn.
|
|
288
|
+
active_hints = chrome_hint_pairs(edit_hints if edit_mode else base_hints)
|
|
289
|
+
hint_res = _hint(*active_hints)
|
|
290
|
+
hint_raw = hint_res[0] if isinstance(hint_res, tuple) else hint_res
|
|
291
|
+
hint_lines = hint_raw.split("\n") if hint_raw else []
|
|
292
|
+
|
|
293
|
+
_LEDIT_HEADER_ROWS = 4 # message + separator + col-headers + col-underline
|
|
294
|
+
_LEDIT_FOOTER_ROWS = 1 # bottom separator (hints follow immediately)
|
|
295
|
+
fixed_overhead = _LEDIT_HEADER_ROWS + _LEDIT_FOOTER_ROWS + len(hint_lines)
|
|
296
|
+
vis = max(2, _visible_rows() - fixed_overhead)
|
|
297
|
+
n = len(items)
|
|
298
|
+
|
|
299
|
+
if cursor < viewport:
|
|
300
|
+
viewport = cursor
|
|
301
|
+
elif cursor >= viewport + vis:
|
|
302
|
+
viewport = cursor - vis + 1
|
|
303
|
+
# See select(): don't leave the list scrolled once more rows fit.
|
|
304
|
+
viewport = max(0, min(viewport, n - vis))
|
|
305
|
+
|
|
306
|
+
if n == 0:
|
|
307
|
+
out.append(f" {C.DIM}(empty list){C.RESET}")
|
|
308
|
+
else:
|
|
309
|
+
hints = barrel_hints or []
|
|
310
|
+
n_hints = len(hints)
|
|
311
|
+
cur_text = hints[barrel_idx] if barrel_mode and hints else ""
|
|
312
|
+
show_above = barrel_mode and n_hints >= 3
|
|
313
|
+
prev_text = hints[(barrel_idx - 1) % n_hints] if show_above else ""
|
|
314
|
+
next_text = hints[(barrel_idx + 1) % n_hints] if barrel_mode and n_hints >= 2 else ""
|
|
315
|
+
|
|
316
|
+
for i in range(viewport, min(viewport + vis, n)):
|
|
317
|
+
item = items[i]
|
|
318
|
+
is_sel = (i == cursor)
|
|
319
|
+
|
|
320
|
+
row_is_editing = (is_sel and edit_mode)
|
|
321
|
+
cursor_glyph = f"{C.ACCENT}›{C.RESET}" if (is_sel and not edit_mode) else (" " if not row_is_editing else "✎")
|
|
322
|
+
|
|
323
|
+
if num_cols > 1:
|
|
324
|
+
i_vals = list(item) if isinstance(item, (list, tuple)) else [str(item)]
|
|
325
|
+
while len(i_vals) < num_cols: i_vals.append("")
|
|
326
|
+
|
|
327
|
+
if row_is_editing and barrel_mode:
|
|
328
|
+
def _barrel_above(w: int, is_barrel_col: bool) -> str:
|
|
329
|
+
"""Preview line for the barrel-mode value one step before the current one."""
|
|
330
|
+
if not is_barrel_col:
|
|
331
|
+
return " " * w
|
|
332
|
+
if prev_text:
|
|
333
|
+
return f"{C.ACCENT}⌃{C.RESET} {C.DIM}{ui.truncate_text(prev_text, w - 2)}{C.RESET}"
|
|
334
|
+
return " " * w
|
|
335
|
+
|
|
336
|
+
def _barrel_mid(w: int, is_barrel_col: bool, val: str) -> str:
|
|
337
|
+
"""Current barrel-mode value (or the plain cell) for this column."""
|
|
338
|
+
if not is_barrel_col:
|
|
339
|
+
return f"{val:<{w}}"
|
|
340
|
+
return f"{C.PRIMARY}{C.BOLD}{ui.truncate_text(cur_text, w)}{C.RESET}"
|
|
341
|
+
|
|
342
|
+
def _barrel_below(w: int, is_barrel_col: bool) -> str:
|
|
343
|
+
"""Preview line for the barrel-mode value one step after the current one."""
|
|
344
|
+
if not is_barrel_col:
|
|
345
|
+
return " " * w
|
|
346
|
+
if next_text:
|
|
347
|
+
return f"{C.ACCENT}⌄{C.RESET} {C.DIM}{ui.truncate_text(next_text, w - 2)}{C.RESET}"
|
|
348
|
+
return " " * w
|
|
349
|
+
|
|
350
|
+
mid_parts, below_parts = [], []
|
|
351
|
+
above_parts: list[str] = []
|
|
352
|
+
for j in range(num_cols - 1):
|
|
353
|
+
bc = (edit_col == j)
|
|
354
|
+
above_parts.append(_barrel_above(col_widths[j], bc))
|
|
355
|
+
mid_parts.append(_barrel_mid(col_widths[j], bc, str(i_vals[j])))
|
|
356
|
+
below_parts.append(_barrel_below(col_widths[j], bc))
|
|
357
|
+
bc_last = (edit_col == num_cols - 1)
|
|
358
|
+
above_parts.append(_barrel_above(last_w, bc_last))
|
|
359
|
+
mid_parts.append(_barrel_mid(last_w, bc_last, str(i_vals[-1])))
|
|
360
|
+
below_parts.append(_barrel_below(last_w, bc_last))
|
|
361
|
+
|
|
362
|
+
sep = " "
|
|
363
|
+
if show_above:
|
|
364
|
+
out.append(f" {sep.join(above_parts)}")
|
|
365
|
+
out.append(f" ✎ {sep.join(mid_parts)}")
|
|
366
|
+
out.append(f" {sep.join(below_parts)}")
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
types = col_types or {}
|
|
370
|
+
# The selected row is wrapped in colour + bold below; cells have
|
|
371
|
+
# to re-assert that after any span they close, or the row loses
|
|
372
|
+
# its styling from the first styled character onward.
|
|
373
|
+
row_base = f"{C.PRIMARY}{C.BOLD}" if (is_sel and not edit_mode) else ''
|
|
374
|
+
row_parts = []
|
|
375
|
+
for j in range(num_cols - 1):
|
|
376
|
+
cw = col_widths[j]
|
|
377
|
+
ts = types.get(j) == 'timestamp'
|
|
378
|
+
cell_str = _render_list_edit_cell(str(i_vals[j]), cw, row_is_editing,
|
|
379
|
+
edit_col == j, edit_buf, edit_pos, ts, row_base)
|
|
380
|
+
row_parts.append(f"{cell_str:<{cw}}"
|
|
381
|
+
if not ((row_is_editing and edit_col == j) or ts) else cell_str)
|
|
382
|
+
|
|
383
|
+
last_cell = _render_list_edit_cell(str(i_vals[-1]), last_w, row_is_editing,
|
|
384
|
+
edit_col == (num_cols - 1), edit_buf, edit_pos,
|
|
385
|
+
types.get(num_cols - 1) == 'timestamp', row_base)
|
|
386
|
+
row_parts.append(last_cell)
|
|
387
|
+
|
|
388
|
+
row_str = " ".join(row_parts)
|
|
389
|
+
|
|
390
|
+
if is_sel and not edit_mode:
|
|
391
|
+
out.append(f" {cursor_glyph} {C.PRIMARY}{C.BOLD}{row_str}{C.RESET}")
|
|
392
|
+
else:
|
|
393
|
+
out.append(f" {cursor_glyph} {row_str}")
|
|
394
|
+
else:
|
|
395
|
+
val_str = str(item)
|
|
396
|
+
|
|
397
|
+
if row_is_editing and barrel_mode:
|
|
398
|
+
w = inner - 4
|
|
399
|
+
mid = f"{C.PRIMARY}{C.BOLD}{ui.truncate_text(cur_text, w)}{C.RESET}"
|
|
400
|
+
if show_above and prev_text:
|
|
401
|
+
out.append(f" {C.ACCENT}⌃{C.RESET} {C.DIM}{ui.truncate_text(prev_text, w - 2)}{C.RESET}")
|
|
402
|
+
out.append(f" ✎ {mid}")
|
|
403
|
+
if next_text:
|
|
404
|
+
out.append(f" {C.ACCENT}⌄{C.RESET} {C.DIM}{ui.truncate_text(next_text, w - 2)}{C.RESET}")
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
cell_str = _render_list_edit_cell(
|
|
408
|
+
val_str, inner - 4, row_is_editing, True, edit_buf, edit_pos,
|
|
409
|
+
(col_types or {}).get(0) == 'timestamp',
|
|
410
|
+
f"{C.PRIMARY}{C.BOLD}" if (is_sel and not edit_mode) else '')
|
|
411
|
+
if is_sel and not edit_mode:
|
|
412
|
+
out.append(f" {cursor_glyph} {C.PRIMARY}{C.BOLD}{cell_str}{C.RESET}")
|
|
413
|
+
else:
|
|
414
|
+
out.append(f" {cursor_glyph} {cell_str}")
|
|
415
|
+
|
|
416
|
+
# Pin the bottom separator + hint bar to the bottom of the screen.
|
|
417
|
+
_filler = _hint_pin_target() - len(out) - 1 - len(hint_lines)
|
|
418
|
+
if _filler > 0:
|
|
419
|
+
out.extend([""] * _filler)
|
|
420
|
+
out.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
|
|
421
|
+
out.extend(f"{' ' * ui.MARGIN_H}{h}" for h in hint_lines)
|
|
422
|
+
|
|
423
|
+
return out, viewport, vis, _LEDIT_HEADER_ROWS, active_hints, len(hint_lines)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _parse_import_rows(text: str, headers: tuple[str, ...]) -> list:
|
|
427
|
+
"""Parse pasted/imported text into list_edit rows, auto-detecting the layout.
|
|
428
|
+
|
|
429
|
+
Handles CSV, TSV, and other separators (``;`` ``|`` ``:`` `` - ``) plus
|
|
430
|
+
run-of-2+-spaces columns. Comment (`#`) and blank lines are skipped, and a
|
|
431
|
+
leading header row that just repeats the column names is dropped."""
|
|
432
|
+
num_cols = len(headers)
|
|
433
|
+
lines = [ln.strip() for ln in text.splitlines()]
|
|
434
|
+
lines = [ln for ln in lines if ln and not ln.startswith('#')]
|
|
435
|
+
if not lines:
|
|
436
|
+
return []
|
|
437
|
+
if num_cols <= 1:
|
|
438
|
+
return list(lines)
|
|
439
|
+
|
|
440
|
+
# Delimiter present in a majority of lines wins (tab/comma preferred).
|
|
441
|
+
delim = None
|
|
442
|
+
for cand in ('\t', ',', ';', '|', ' - ', ':'):
|
|
443
|
+
if sum(1 for ln in lines if cand in ln) >= max(1, (len(lines) + 1) // 2):
|
|
444
|
+
delim = cand
|
|
445
|
+
break
|
|
446
|
+
|
|
447
|
+
def _fit(fields: list) -> tuple:
|
|
448
|
+
"""Pad/collapse a split row to exactly num_cols fields, folding overflow into the last column."""
|
|
449
|
+
fields = [f.strip() for f in fields]
|
|
450
|
+
if len(fields) > num_cols: # overflow → last column keeps the rest
|
|
451
|
+
tail = (delim or ' ').join(fields[num_cols - 1:]).strip()
|
|
452
|
+
fields = fields[:num_cols - 1] + [tail]
|
|
453
|
+
fields += [''] * (num_cols - len(fields))
|
|
454
|
+
return tuple(fields[:num_cols])
|
|
455
|
+
|
|
456
|
+
rows: list = []
|
|
457
|
+
if delim == ',':
|
|
458
|
+
import csv
|
|
459
|
+
import io
|
|
460
|
+
for fields in csv.reader(io.StringIO('\n'.join(lines))):
|
|
461
|
+
rows.append(_fit(fields))
|
|
462
|
+
elif delim:
|
|
463
|
+
for ln in lines:
|
|
464
|
+
rows.append(_fit(ln.split(delim, num_cols - 1)))
|
|
465
|
+
else:
|
|
466
|
+
for ln in lines: # no delimiter → split on runs of spaces
|
|
467
|
+
parts = re.split(r'\s{2,}', ln)
|
|
468
|
+
rows.append(_fit(parts if len(parts) >= num_cols else [ln]))
|
|
469
|
+
|
|
470
|
+
if rows and tuple(str(c).lower() for c in rows[0]) == tuple(h.lower() for h in headers):
|
|
471
|
+
rows = rows[1:] # drop a repeated-header row
|
|
472
|
+
return rows
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def list_edit(message: str, initial_items: list | None = None, headers: tuple[str, ...] = ("ROLE", "NAME"),
|
|
476
|
+
fixed_rows: bool = False, locked_cols: set | None = None,
|
|
477
|
+
col_ratios: tuple | None = None, col_hints: object = None,
|
|
478
|
+
col_mins: tuple | None = None, col_types: dict | None = None) -> list | None:
|
|
479
|
+
"""Arrow keys navigate, 'a' adds, 'e' edits in-place, 'd' deletes, Enter saves.
|
|
480
|
+
'i' imports rows typed in the system editor, 'f' imports them from a file.
|
|
481
|
+
|
|
482
|
+
Supports in-place cell editing with Tab navigation between columns.
|
|
483
|
+
fixed_rows: disables add/delete (rows can only be edited, not added or removed).
|
|
484
|
+
locked_cols: set of column indices that cannot be edited.
|
|
485
|
+
col_ratios: relative starting widths for the columns.
|
|
486
|
+
col_hints: callable(col, row) -> candidate values for a cell. With two or
|
|
487
|
+
more, and the cell's value among them, the cell opens as a
|
|
488
|
+
barrel that cycles through them instead of free text.
|
|
489
|
+
col_mins: per-column minimum widths, honoured before the ratios: this is
|
|
490
|
+
what keeps a fixed-shape cell readable when the table is narrow.
|
|
491
|
+
col_types: {column index: type} for cells that edit as something other than
|
|
492
|
+
free text. ``'timestamp'`` gives a split date/time field masked
|
|
493
|
+
as ``YYYY-MM-DD HH:MM:SS``: you type only the digits, and the
|
|
494
|
+
left/right arrows run past the end of one part straight into the
|
|
495
|
+
next rather than needing Tab. Leaving the time blank keeps the
|
|
496
|
+
value a plain date.
|
|
497
|
+
"""
|
|
498
|
+
items = list(initial_items) if initial_items else []
|
|
499
|
+
cursor = 0
|
|
500
|
+
viewport = 0
|
|
501
|
+
fd = sys.stdin.fileno()
|
|
502
|
+
old = _get_term_attrs(fd)
|
|
503
|
+
w = _Widget(fd)
|
|
504
|
+
num_cols = len(headers)
|
|
505
|
+
|
|
506
|
+
edit_mode = False
|
|
507
|
+
edit_col = 0
|
|
508
|
+
edit_buf: list[str] = []
|
|
509
|
+
edit_pos = 0
|
|
510
|
+
edit_backup = None
|
|
511
|
+
barrel_mode = False
|
|
512
|
+
barrel_idx = 0
|
|
513
|
+
barrel_hints: list[str] = []
|
|
514
|
+
|
|
515
|
+
def _is_ts(col: int) -> bool:
|
|
516
|
+
"""True if `col` edits as a split timestamp rather than free text."""
|
|
517
|
+
return (col_types or {}).get(col) == 'timestamp'
|
|
518
|
+
|
|
519
|
+
def _seed_edit(col: int, value: str) -> tuple:
|
|
520
|
+
"""Opening buffer and caret for editing `col`: a mask for a timestamp
|
|
521
|
+
column, otherwise the plain text with the caret at its end."""
|
|
522
|
+
if _is_ts(col):
|
|
523
|
+
buf = _ts_buffer(value)
|
|
524
|
+
return buf, _TS_SLOTS[0]
|
|
525
|
+
buf = list(str(value))
|
|
526
|
+
return buf, len(buf)
|
|
527
|
+
|
|
528
|
+
def _get_cell_hints() -> list[str]:
|
|
529
|
+
"""Candidate values for the current cell from `col_hints`, or [] if unavailable."""
|
|
530
|
+
if col_hints is None:
|
|
531
|
+
return []
|
|
532
|
+
curr = items[cursor] if items else None
|
|
533
|
+
if curr is None:
|
|
534
|
+
return []
|
|
535
|
+
row = list(curr) if isinstance(curr, (list, tuple)) else [str(curr)]
|
|
536
|
+
try:
|
|
537
|
+
return list(col_hints(edit_col, row)) # type: ignore[operator]
|
|
538
|
+
except Exception:
|
|
539
|
+
return []
|
|
540
|
+
|
|
541
|
+
_le_vis: int = 2
|
|
542
|
+
_le_header_rows: int = 4
|
|
543
|
+
# Maps an absolute (row, col) on a hint line → the key clicking it replays
|
|
544
|
+
# (populated only in non-edit mode, where those hints are actionable).
|
|
545
|
+
_hint_cells: dict[tuple[int, int], str] = {}
|
|
546
|
+
|
|
547
|
+
def _render():
|
|
548
|
+
nonlocal viewport, _le_vis, _le_header_rows
|
|
549
|
+
lines, new_viewport, new_vis, new_hdr, active_hints, n_hint = _build_list_edit_lines(
|
|
550
|
+
message, items, headers,
|
|
551
|
+
cursor, viewport,
|
|
552
|
+
edit_mode, edit_col, edit_buf, edit_pos,
|
|
553
|
+
fixed_rows, barrel_mode, barrel_hints, barrel_idx,
|
|
554
|
+
col_ratios, col_mins, col_types,
|
|
555
|
+
)
|
|
556
|
+
viewport = new_viewport
|
|
557
|
+
_le_vis = new_vis
|
|
558
|
+
_le_header_rows = new_hdr
|
|
559
|
+
_hint_cells.clear()
|
|
560
|
+
if n_hint: # edit-mode hints too: a hint shown is a key that works
|
|
561
|
+
_hp = list(active_hints)
|
|
562
|
+
_start = len(lines) - n_hint
|
|
563
|
+
for _k in range(n_hint):
|
|
564
|
+
add_hint_click_cells(_hint_cells, lines[_start + _k],
|
|
565
|
+
1 + ui.MARGIN_V + (_start + _k), _hp)
|
|
566
|
+
if lines: # `?` is typed while a cell is being edited
|
|
567
|
+
lines[0] = add_help_corner(lines[0], 1 + ui.MARGIN_V, _hint_cells, help_key=not edit_mode)
|
|
568
|
+
w.render(lines)
|
|
569
|
+
|
|
570
|
+
def _commit_edit_buffer():
|
|
571
|
+
"""Write the in-progress edit buffer back into the current row, padding short rows to num_cols."""
|
|
572
|
+
val = _ts_value(edit_buf) if _is_ts(edit_col) else "".join(edit_buf)
|
|
573
|
+
if num_cols > 1:
|
|
574
|
+
curr = list(items[cursor]) if isinstance(items[cursor], (list, tuple)) else [str(items[cursor])]
|
|
575
|
+
while len(curr) < num_cols: curr.append("")
|
|
576
|
+
curr[edit_col] = val
|
|
577
|
+
items[cursor] = tuple(curr)
|
|
578
|
+
else:
|
|
579
|
+
items[cursor] = val
|
|
580
|
+
|
|
581
|
+
result = None
|
|
582
|
+
_le_last_click: int | None = None
|
|
583
|
+
try:
|
|
584
|
+
_set_raw(fd)
|
|
585
|
+
enable_mouse()
|
|
586
|
+
screen_takeover_next() # paint over the previous screen, no flash
|
|
587
|
+
_render()
|
|
588
|
+
|
|
589
|
+
while True:
|
|
590
|
+
if ui.consume_resize():
|
|
591
|
+
ui.clear_screen()
|
|
592
|
+
w.anchor_reset()
|
|
593
|
+
_le_last_click = None
|
|
594
|
+
_render()
|
|
595
|
+
continue
|
|
596
|
+
|
|
597
|
+
if not _wait_for_keypress(0.05):
|
|
598
|
+
continue
|
|
599
|
+
|
|
600
|
+
key = _read_key(fd)
|
|
601
|
+
|
|
602
|
+
# Transport keys and miniplayer/hint clicks work in every mode of
|
|
603
|
+
# this widget, so they are consumed before the mode switches below.
|
|
604
|
+
_ch = consume_chrome(key, _hint_cells)
|
|
605
|
+
if _ch is CHROME_HANDLED:
|
|
606
|
+
continue
|
|
607
|
+
if _ch is CHROME_REDRAW:
|
|
608
|
+
w.anchor_reset(); _render(); continue
|
|
609
|
+
if _ch is not None:
|
|
610
|
+
key = _ch
|
|
611
|
+
|
|
612
|
+
# After the replay, so a clicked ^t toggles as the typed one does.
|
|
613
|
+
if chrome.is_mode_toggle(key) and not edit_mode:
|
|
614
|
+
return MODE_TOGGLE # type: ignore[return-value]
|
|
615
|
+
|
|
616
|
+
if edit_mode and barrel_mode:
|
|
617
|
+
if key == 'ESC':
|
|
618
|
+
items[cursor] = edit_backup
|
|
619
|
+
edit_mode = False
|
|
620
|
+
barrel_mode = False
|
|
621
|
+
_render()
|
|
622
|
+
|
|
623
|
+
elif key == 'UP' and barrel_hints:
|
|
624
|
+
barrel_idx = (barrel_idx - 1) % len(barrel_hints)
|
|
625
|
+
_render()
|
|
626
|
+
|
|
627
|
+
elif key == 'DOWN' and barrel_hints:
|
|
628
|
+
barrel_idx = (barrel_idx + 1) % len(barrel_hints)
|
|
629
|
+
_render()
|
|
630
|
+
|
|
631
|
+
elif key == 'ENTER':
|
|
632
|
+
if barrel_hints:
|
|
633
|
+
edit_buf = list(barrel_hints[barrel_idx])
|
|
634
|
+
edit_pos = len(edit_buf)
|
|
635
|
+
_commit_edit_buffer()
|
|
636
|
+
barrel_mode = False
|
|
637
|
+
edit_mode = False
|
|
638
|
+
_render()
|
|
639
|
+
|
|
640
|
+
elif key in ('TAB', 'BACKTAB') and num_cols > 1:
|
|
641
|
+
if barrel_hints:
|
|
642
|
+
edit_buf = list(barrel_hints[barrel_idx])
|
|
643
|
+
edit_pos = len(edit_buf)
|
|
644
|
+
_commit_edit_buffer()
|
|
645
|
+
barrel_mode = False
|
|
646
|
+
_step = -1 if key == 'BACKTAB' else 1
|
|
647
|
+
next_col = (edit_col + _step) % num_cols
|
|
648
|
+
if locked_cols:
|
|
649
|
+
steps = 0
|
|
650
|
+
while next_col in locked_cols and steps < num_cols:
|
|
651
|
+
next_col = (next_col + _step) % num_cols
|
|
652
|
+
steps += 1
|
|
653
|
+
edit_col = next_col
|
|
654
|
+
curr = items[cursor]
|
|
655
|
+
i_vals = list(curr) if isinstance(curr, (list, tuple)) else [str(curr)]
|
|
656
|
+
while len(i_vals) < num_cols: i_vals.append("")
|
|
657
|
+
edit_buf, edit_pos = _seed_edit(edit_col, str(i_vals[edit_col]))
|
|
658
|
+
barrel_hints = [] if _is_ts(edit_col) else _get_cell_hints()
|
|
659
|
+
cur_val = "".join(edit_buf)
|
|
660
|
+
if len(barrel_hints) >= 2 and cur_val in barrel_hints:
|
|
661
|
+
barrel_mode = True
|
|
662
|
+
barrel_idx = barrel_hints.index(cur_val)
|
|
663
|
+
else:
|
|
664
|
+
barrel_mode = False
|
|
665
|
+
barrel_idx = 0
|
|
666
|
+
_render()
|
|
667
|
+
|
|
668
|
+
elif len(key) == 1 and key.isprintable():
|
|
669
|
+
# Exit barrel → free-text mode, seed buffer with this char
|
|
670
|
+
barrel_mode = False
|
|
671
|
+
edit_buf = [key]
|
|
672
|
+
edit_pos = 1
|
|
673
|
+
_render()
|
|
674
|
+
|
|
675
|
+
elif edit_mode and _is_ts(edit_col):
|
|
676
|
+
# Split timestamp cell: only digits go in, and the caret walks the
|
|
677
|
+
# whole mask so running off the end of one part lands in the next.
|
|
678
|
+
if key == 'ESC':
|
|
679
|
+
items[cursor] = edit_backup
|
|
680
|
+
edit_mode = False
|
|
681
|
+
_render()
|
|
682
|
+
|
|
683
|
+
elif key == 'ENTER':
|
|
684
|
+
_commit_edit_buffer()
|
|
685
|
+
edit_mode = False
|
|
686
|
+
_render()
|
|
687
|
+
|
|
688
|
+
elif key in ('TAB', 'BACKTAB') and num_cols > 1:
|
|
689
|
+
_commit_edit_buffer()
|
|
690
|
+
_step = -1 if key == 'BACKTAB' else 1
|
|
691
|
+
next_col = (edit_col + _step) % num_cols
|
|
692
|
+
if locked_cols:
|
|
693
|
+
steps = 0
|
|
694
|
+
while next_col in locked_cols and steps < num_cols:
|
|
695
|
+
next_col = (next_col + _step) % num_cols
|
|
696
|
+
steps += 1
|
|
697
|
+
edit_col = next_col
|
|
698
|
+
curr = items[cursor]
|
|
699
|
+
i_vals = list(curr) if isinstance(curr, (list, tuple)) else [str(curr)]
|
|
700
|
+
while len(i_vals) < num_cols: i_vals.append("")
|
|
701
|
+
edit_buf, edit_pos = _seed_edit(edit_col, str(i_vals[edit_col]))
|
|
702
|
+
barrel_hints = [] if _is_ts(edit_col) else _get_cell_hints()
|
|
703
|
+
cur_val = "".join(edit_buf)
|
|
704
|
+
if len(barrel_hints) >= 2 and cur_val in barrel_hints:
|
|
705
|
+
barrel_mode = True
|
|
706
|
+
barrel_idx = barrel_hints.index(cur_val)
|
|
707
|
+
else:
|
|
708
|
+
barrel_mode = False
|
|
709
|
+
barrel_idx = 0
|
|
710
|
+
_render()
|
|
711
|
+
|
|
712
|
+
elif key == 'LEFT':
|
|
713
|
+
edit_pos = _ts_step(edit_pos, -1)
|
|
714
|
+
_render()
|
|
715
|
+
|
|
716
|
+
elif key == 'RIGHT':
|
|
717
|
+
edit_pos = _ts_step(edit_pos, 1)
|
|
718
|
+
_render()
|
|
719
|
+
|
|
720
|
+
elif key == 'HOME':
|
|
721
|
+
edit_pos = _TS_SLOTS[0]
|
|
722
|
+
_render()
|
|
723
|
+
|
|
724
|
+
elif key == 'END':
|
|
725
|
+
edit_pos = _TS_SLOTS[-1]
|
|
726
|
+
_render()
|
|
727
|
+
|
|
728
|
+
elif key == 'BACKSPACE':
|
|
729
|
+
# Clear the slot behind the caret and sit on it, so holding
|
|
730
|
+
# backspace rubs the stamp out right-to-left.
|
|
731
|
+
prev = _ts_step(edit_pos, -1)
|
|
732
|
+
if prev != edit_pos or edit_pos == _TS_SLOTS[0]:
|
|
733
|
+
target = prev if prev != edit_pos else edit_pos
|
|
734
|
+
edit_buf[target] = _TS_MASK[target]
|
|
735
|
+
edit_pos = target
|
|
736
|
+
_render()
|
|
737
|
+
|
|
738
|
+
elif key == 'DELETE':
|
|
739
|
+
edit_buf[edit_pos] = _TS_MASK[edit_pos]
|
|
740
|
+
_render()
|
|
741
|
+
|
|
742
|
+
elif len(key) == 1 and key.isdigit():
|
|
743
|
+
edit_buf[edit_pos] = key
|
|
744
|
+
nxt = _ts_step(edit_pos, 1)
|
|
745
|
+
edit_pos = nxt if nxt != edit_pos else edit_pos
|
|
746
|
+
_render()
|
|
747
|
+
|
|
748
|
+
# Anything else (letters, punctuation) is simply not accepted:
|
|
749
|
+
# the mask supplies every separator already.
|
|
750
|
+
|
|
751
|
+
elif edit_mode:
|
|
752
|
+
if key == 'ESC':
|
|
753
|
+
items[cursor] = edit_backup
|
|
754
|
+
edit_mode = False
|
|
755
|
+
_render()
|
|
756
|
+
|
|
757
|
+
elif key == 'ENTER':
|
|
758
|
+
_commit_edit_buffer()
|
|
759
|
+
edit_mode = False
|
|
760
|
+
_render()
|
|
761
|
+
|
|
762
|
+
elif key in ('TAB', 'BACKTAB'):
|
|
763
|
+
if num_cols > 1:
|
|
764
|
+
_commit_edit_buffer()
|
|
765
|
+
_step = -1 if key == 'BACKTAB' else 1
|
|
766
|
+
next_col = (edit_col + _step) % num_cols
|
|
767
|
+
# Skip locked columns when tabbing (either direction).
|
|
768
|
+
if locked_cols:
|
|
769
|
+
steps = 0
|
|
770
|
+
while next_col in locked_cols and steps < num_cols:
|
|
771
|
+
next_col = (next_col + _step) % num_cols
|
|
772
|
+
steps += 1
|
|
773
|
+
edit_col = next_col
|
|
774
|
+
|
|
775
|
+
curr = items[cursor]
|
|
776
|
+
i_vals = list(curr) if isinstance(curr, (list, tuple)) else [str(curr)]
|
|
777
|
+
while len(i_vals) < num_cols: i_vals.append("")
|
|
778
|
+
|
|
779
|
+
edit_buf, edit_pos = _seed_edit(edit_col, str(i_vals[edit_col]))
|
|
780
|
+
barrel_hints = [] if _is_ts(edit_col) else _get_cell_hints()
|
|
781
|
+
cur_val = "".join(edit_buf)
|
|
782
|
+
if len(barrel_hints) >= 2 and cur_val in barrel_hints:
|
|
783
|
+
barrel_mode = True
|
|
784
|
+
barrel_idx = barrel_hints.index(cur_val)
|
|
785
|
+
else:
|
|
786
|
+
barrel_mode = False
|
|
787
|
+
barrel_idx = 0
|
|
788
|
+
_render()
|
|
789
|
+
|
|
790
|
+
elif key == 'BACKSPACE' and edit_pos == 0 and len(barrel_hints) >= 2:
|
|
791
|
+
barrel_mode = True
|
|
792
|
+
barrel_idx = 0
|
|
793
|
+
_render()
|
|
794
|
+
|
|
795
|
+
elif (new_pos := edit_line(edit_buf, edit_pos, key)) is not None:
|
|
796
|
+
edit_pos = new_pos
|
|
797
|
+
_render()
|
|
798
|
+
|
|
799
|
+
else:
|
|
800
|
+
if key.startswith('MOUSE_CLICK:'):
|
|
801
|
+
# Now-playing box / hint-glyph clicks first; a plain row click
|
|
802
|
+
# falls through to the row-selection handler below.
|
|
803
|
+
_mp = key.split(':')
|
|
804
|
+
_mr = int(_mp[2]); _mc = int(_mp[3]) if len(_mp) > 3 else 1
|
|
805
|
+
_act = footer_click_action(_mr, _mc)
|
|
806
|
+
if _act == 'open' and chrome._player_opener is not None:
|
|
807
|
+
chrome._player_opener()
|
|
808
|
+
enable_mouse()
|
|
809
|
+
sys.stdout.flush()
|
|
810
|
+
w.anchor_reset(); _le_last_click = None; _render(); continue
|
|
811
|
+
if _act in ('playpause', 'next', 'prev') and chrome._transport_handler is not None:
|
|
812
|
+
chrome._transport_handler(_act); continue
|
|
813
|
+
_hk = _hint_cells.get((_mr, _mc))
|
|
814
|
+
if _hk is not None:
|
|
815
|
+
key = _hk # replay the hint's key through the switch
|
|
816
|
+
|
|
817
|
+
act = keys.action(key, "table")
|
|
818
|
+
if key == 'CTRL_C':
|
|
819
|
+
break
|
|
820
|
+
elif key == 'SCROLL_UP':
|
|
821
|
+
if items: cursor = (cursor - 1) % len(items)
|
|
822
|
+
_le_last_click = None
|
|
823
|
+
_render()
|
|
824
|
+
elif key == 'SCROLL_DOWN':
|
|
825
|
+
if items: cursor = (cursor + 1) % len(items)
|
|
826
|
+
_le_last_click = None
|
|
827
|
+
_render()
|
|
828
|
+
elif key.startswith('MOUSE_CLICK:'):
|
|
829
|
+
_parts = key.split(':')
|
|
830
|
+
_btn, _mrow = int(_parts[1]), int(_parts[2])
|
|
831
|
+
if _btn == 0 and items:
|
|
832
|
+
# render() prepends MARGIN_V blank rows before lines[0]
|
|
833
|
+
_line_idx = _mrow - 1 - ui.MARGIN_V
|
|
834
|
+
_item_offset = _line_idx - _le_header_rows
|
|
835
|
+
if 0 <= _item_offset < _le_vis:
|
|
836
|
+
_clicked_idx = viewport + _item_offset
|
|
837
|
+
if 0 <= _clicked_idx < len(items):
|
|
838
|
+
if _le_last_click == _clicked_idx:
|
|
839
|
+
cursor = _clicked_idx
|
|
840
|
+
result = items
|
|
841
|
+
break
|
|
842
|
+
else:
|
|
843
|
+
_le_last_click = _clicked_idx
|
|
844
|
+
cursor = _clicked_idx
|
|
845
|
+
_render()
|
|
846
|
+
elif act == 'list.up':
|
|
847
|
+
if items: cursor = (cursor - 1) % len(items)
|
|
848
|
+
_le_last_click = None
|
|
849
|
+
_render()
|
|
850
|
+
elif act == 'list.down':
|
|
851
|
+
if items: cursor = (cursor + 1) % len(items)
|
|
852
|
+
_le_last_click = None
|
|
853
|
+
_render()
|
|
854
|
+
|
|
855
|
+
elif act == 'table.add' and not fixed_rows:
|
|
856
|
+
empty_item = tuple(["" for _ in range(num_cols)]) if num_cols > 1 else ""
|
|
857
|
+
items.append(empty_item)
|
|
858
|
+
cursor = len(items) - 1
|
|
859
|
+
|
|
860
|
+
edit_mode = True
|
|
861
|
+
edit_col = 0
|
|
862
|
+
edit_buf = []
|
|
863
|
+
edit_pos = 0
|
|
864
|
+
edit_backup = empty_item
|
|
865
|
+
_render()
|
|
866
|
+
|
|
867
|
+
elif act == 'table.edit' and items:
|
|
868
|
+
edit_mode = True
|
|
869
|
+
# Start on the first non-locked column.
|
|
870
|
+
edit_col = 0
|
|
871
|
+
if locked_cols:
|
|
872
|
+
while edit_col < num_cols and edit_col in locked_cols:
|
|
873
|
+
edit_col += 1
|
|
874
|
+
if edit_col >= num_cols:
|
|
875
|
+
edit_col = 0 # all cols locked: allow no editing
|
|
876
|
+
edit_backup = items[cursor]
|
|
877
|
+
|
|
878
|
+
if num_cols > 1:
|
|
879
|
+
curr = items[cursor]
|
|
880
|
+
i_vals = list(curr) if isinstance(curr, (list, tuple)) else [str(curr)]
|
|
881
|
+
while len(i_vals) < num_cols: i_vals.append("")
|
|
882
|
+
edit_buf, edit_pos = _seed_edit(edit_col, str(i_vals[edit_col]))
|
|
883
|
+
else:
|
|
884
|
+
edit_buf = list(str(items[cursor]))
|
|
885
|
+
edit_pos = len(edit_buf)
|
|
886
|
+
|
|
887
|
+
barrel_hints = [] if _is_ts(edit_col) else _get_cell_hints()
|
|
888
|
+
cur_val = "".join(edit_buf)
|
|
889
|
+
if len(barrel_hints) >= 2 and cur_val in barrel_hints:
|
|
890
|
+
barrel_mode = True
|
|
891
|
+
barrel_idx = barrel_hints.index(cur_val)
|
|
892
|
+
else:
|
|
893
|
+
barrel_mode = False
|
|
894
|
+
barrel_idx = 0
|
|
895
|
+
_render()
|
|
896
|
+
|
|
897
|
+
elif act == 'table.delete' and items and not fixed_rows:
|
|
898
|
+
items.pop(cursor)
|
|
899
|
+
if items:
|
|
900
|
+
cursor = min(cursor, len(items) - 1)
|
|
901
|
+
else:
|
|
902
|
+
cursor = 0
|
|
903
|
+
_render()
|
|
904
|
+
|
|
905
|
+
elif keys.pressed(key, "list.move_up") and items and not fixed_rows and cursor > 0:
|
|
906
|
+
items[cursor - 1], items[cursor] = items[cursor], items[cursor - 1]
|
|
907
|
+
cursor -= 1
|
|
908
|
+
_le_last_click = None
|
|
909
|
+
_render()
|
|
910
|
+
|
|
911
|
+
elif keys.pressed(key, "list.move_down") and items and not fixed_rows and cursor < len(items) - 1:
|
|
912
|
+
items[cursor + 1], items[cursor] = items[cursor], items[cursor + 1]
|
|
913
|
+
cursor += 1
|
|
914
|
+
_le_last_click = None
|
|
915
|
+
_render()
|
|
916
|
+
|
|
917
|
+
elif act == 'list.choose':
|
|
918
|
+
result = items
|
|
919
|
+
break
|
|
920
|
+
|
|
921
|
+
elif act in ('table.import_text', 'table.import_file'):
|
|
922
|
+
if act == 'table.import_text':
|
|
923
|
+
if num_cols > 1:
|
|
924
|
+
template = (
|
|
925
|
+
"# One entry per line: "
|
|
926
|
+
+ " : ".join(h.lower() for h in headers)
|
|
927
|
+
+ "\n# Example:\n"
|
|
928
|
+
+ " : ".join(h.lower() for h in headers)
|
|
929
|
+
+ "\n"
|
|
930
|
+
)
|
|
931
|
+
else:
|
|
932
|
+
template = "# One entry per line\n"
|
|
933
|
+
_restore_term_attrs(fd, old)
|
|
934
|
+
text_input = system_editor_edit(initial_text=template)
|
|
935
|
+
_set_raw(fd)
|
|
936
|
+
else:
|
|
937
|
+
# Path prompt (with completion), then auto-detect the format.
|
|
938
|
+
# Clear to a fresh screen first: path() renders inline from
|
|
939
|
+
# the cursor, so without this it draws over the list and spills.
|
|
940
|
+
_restore_term_attrs(fd, old)
|
|
941
|
+
ui.clear_screen()
|
|
942
|
+
file_path = path("Import from file:")
|
|
943
|
+
_set_raw(fd)
|
|
944
|
+
text_input = None
|
|
945
|
+
if file_path:
|
|
946
|
+
try:
|
|
947
|
+
with open(os.path.expanduser(file_path), encoding='utf-8') as _fp:
|
|
948
|
+
text_input = _fp.read()
|
|
949
|
+
except OSError:
|
|
950
|
+
ui.show_status(f"Couldn't read {file_path}")
|
|
951
|
+
text_input = None
|
|
952
|
+
enable_mouse() # re-arm mouse
|
|
953
|
+
screen_takeover_next() # paint over the previous screen, no flash
|
|
954
|
+
w.anchor_reset()
|
|
955
|
+
if text_input:
|
|
956
|
+
items.extend(_parse_import_rows(text_input, headers))
|
|
957
|
+
cursor = len(items) - 1 if items else 0
|
|
958
|
+
_render()
|
|
959
|
+
|
|
960
|
+
elif act == 'list.quit':
|
|
961
|
+
raise QuitToTerminal() # q quits the app; never a way out of a widget
|
|
962
|
+
|
|
963
|
+
elif act == 'list.back':
|
|
964
|
+
ui.clear_screen()
|
|
965
|
+
# "Discard changes?" → yes = drop edits (original), no = keep edits.
|
|
966
|
+
result = initial_items if confirm("Discard changes?", default=False) else items
|
|
967
|
+
break
|
|
968
|
+
|
|
969
|
+
finally:
|
|
970
|
+
disable_mouse()
|
|
971
|
+
_restore_term_attrs(fd, old)
|
|
972
|
+
w.clear()
|
|
973
|
+
|
|
974
|
+
return result
|