backpack-backbone 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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