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,555 @@
1
+ """Date and date-time pickers."""
2
+ from __future__ import annotations
3
+ import re
4
+ import sys
5
+ import datetime
6
+ import calendar as cal
7
+ from backbone.prompt.core import (
8
+ _get_term_attrs, _set_raw, _restore_term_attrs, _wait_for_keypress, block_cursor,
9
+ _read_key, _Widget, screen_takeover_next,
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 CHROME_HANDLED, CHROME_REDRAW, MODE_TOGGLE, _with_toggle_hint, append_chrome, consume_chrome, disable_mouse, enable_mouse
16
+ from backbone.prompt.text import text
17
+ from backbone.prompt.core import C
18
+
19
+
20
+ keys.define("calendar", "Calendar", [
21
+ ("later", ("RIGHT",), "next month, or day"),
22
+ ("earlier", ("LEFT",), "previous month, or day"),
23
+ ("up", ("UP",), "previous year, or week"),
24
+ ("down", ("DOWN",), "next year, or week"),
25
+ ("switch", ("TAB", "BACKTAB"), "switch between months and days"),
26
+ ("manual", ("m",), "type a date"),
27
+ ("save", ("ENTER",), "save"),
28
+ ("back", ("ESC",), "back"),
29
+ ], within=("list", "global"))
30
+
31
+
32
+ def _is_leap_year(year: int) -> bool:
33
+ """Gregorian leap-year rule."""
34
+ return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0)
35
+
36
+
37
+ def _days_in_month(year: int, month: int) -> int:
38
+ """Number of days in `month` of `year` (Feb accounts for leap years)."""
39
+ if month in (1, 3, 5, 7, 8, 10, 12):
40
+ return 31
41
+ elif month in (4, 6, 9, 11):
42
+ return 30
43
+ elif month == 2:
44
+ return 29 if _is_leap_year(year) else 28
45
+ return 0
46
+
47
+
48
+ def _parse_date(date_str: str) -> tuple[int, int, int] | None:
49
+ """Parse a typed date to ``(year, month, day)``, or None if unreadable.
50
+
51
+ Thin wrapper over the project-wide parser in :mod:`backbone.datetime_parse`
52
+ so the calendar and date/time widgets read dates by exactly the same rules as
53
+ everywhere else. ``dayfirst=False`` preserves this widget's long-standing
54
+ reading of an ambiguous ``02/07/2008`` as month-first; a part over 12 still
55
+ settles the order on its own (``13/07/2008`` is day-first either way).
56
+ """
57
+ return dtp.parse_date_parts(date_str, dayfirst=False)
58
+
59
+
60
+ def calendar_select(message: str = "Select date:", initial: str = "") -> str | None:
61
+ """
62
+ Interactive calendar widget for date selection.
63
+ Allows month/year navigation and in-place day selection.
64
+
65
+ Args:
66
+ message: Prompt label
67
+ initial: Initial date (YYYY-MM-DD or flexible format)
68
+
69
+ Returns:
70
+ Selected date as YYYY-MM-DD string, or None if cancelled
71
+ """
72
+ if initial:
73
+ parsed = _parse_date(initial)
74
+ if parsed:
75
+ y, m, d = parsed
76
+ else:
77
+ # Fallback to today
78
+ today = datetime.date.today()
79
+ y, m, d = today.year, today.month, today.day
80
+ else:
81
+ today = datetime.date.today()
82
+ y, m, d = today.year, today.month, today.day
83
+
84
+ cursor_day = d
85
+ day_mode = False # False: Navigates Month/Year | True: Navigates Days
86
+ _digit_buf = "" # accumulates a leading 1/2/3 so days 10-31 are typable
87
+
88
+ fd = sys.stdin.fileno()
89
+ old = _get_term_attrs(fd)
90
+ w = _Widget(fd)
91
+ _hint_cells: dict = {} # clickable hint keys, filled by append_chrome
92
+
93
+ def _render():
94
+ lines = []
95
+
96
+ # Header
97
+ lines.append(f" {C.DIM}{message}{C.RESET}")
98
+ lines.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
99
+
100
+ # Month/Year display
101
+ month_name = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
102
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m]
103
+
104
+ lines.append(f" {C.BOLD}{month_name} {y}{C.RESET}")
105
+
106
+ lines.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
107
+
108
+ # Day headers
109
+ day_headers = "Mo Tu We Th Fr Sa Su"
110
+ lines.append(f" {day_headers}")
111
+
112
+ # Calendar grid
113
+ cal_obj = cal.monthcalendar(y, m)
114
+
115
+ for week in cal_obj:
116
+ week_parts = []
117
+ for day in week:
118
+ if day == 0:
119
+ week_parts.append(" ")
120
+ else:
121
+ is_selected = (day == cursor_day)
122
+ if is_selected:
123
+ style = f"{C.ACCENT}{C.BOLD}" if day_mode else f"{C.BOLD}"
124
+ week_parts.append(f"{style}{day:2d}{C.RESET} ")
125
+ else:
126
+ week_parts.append(f"{day:2d} ")
127
+ lines.append(f" {''.join(week_parts)}")
128
+
129
+ lines.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
130
+
131
+ L = keys.label
132
+ _cal_pairs = [(L("calendar.save"), "save"), (L("calendar.back"), "back"),
133
+ (L("list.quit"), "quit app"), (L("calendar.switch", first=True), "month/day"),
134
+ (L("calendar.earlier", "calendar.later"), "±1 day" if day_mode else "month"),
135
+ (L("calendar.up", "calendar.down"), "±7 days" if day_mode else "year"),
136
+ (L("calendar.manual"), "manual entry")]
137
+
138
+ append_chrome(lines, _with_toggle_hint(_cal_pairs), _hint_cells)
139
+ w.render(lines)
140
+
141
+ result = None
142
+ try:
143
+ _set_raw(fd)
144
+ enable_mouse() # so the hint keys below can be clicked
145
+ screen_takeover_next() # paint over the previous screen, no flash
146
+ _render()
147
+
148
+ while True:
149
+ if ui.consume_resize():
150
+ ui.clear_screen()
151
+ w.anchor_reset()
152
+ _render()
153
+ continue
154
+
155
+ if not _wait_for_keypress(0.05):
156
+ continue
157
+
158
+ key = _read_key(fd)
159
+ # A transport key, a click on the now-playing box, or a click on one of our
160
+ # own hint keys, handled the same way on every screen.
161
+ _ch = consume_chrome(key, _hint_cells)
162
+ if _ch is CHROME_HANDLED:
163
+ continue
164
+ if _ch is CHROME_REDRAW:
165
+ w.anchor_reset(); _render(); continue
166
+ if _ch is not None:
167
+ key = _ch
168
+
169
+ if chrome.is_mode_toggle(key):
170
+ return MODE_TOGGLE # type: ignore[return-value]
171
+ act = keys.action(key, "calendar")
172
+ if act == 'calendar.save':
173
+ result = f"{y:04d}-{m:02d}-{cursor_day:02d}"
174
+ break
175
+ elif key == 'CTRL_C' or act == 'calendar.back': # Ctrl-C cancels, as in every widget
176
+ break
177
+ elif act == 'list.quit':
178
+ raise QuitToTerminal() # q quits the app; it never just leaves a widget
179
+
180
+ elif act == 'calendar.switch':
181
+ day_mode = not day_mode # two modes: reverse is the same flip
182
+
183
+ elif act == 'calendar.later':
184
+ if not day_mode:
185
+ m += 1
186
+ if m > 12:
187
+ m = 1
188
+ y += 1
189
+ cursor_day = min(cursor_day, _days_in_month(y, m))
190
+ else:
191
+ cursor_day += 1
192
+ if cursor_day > _days_in_month(y, m):
193
+ m += 1
194
+ if m > 12:
195
+ m = 1
196
+ y += 1
197
+ cursor_day = 1
198
+
199
+ elif act == 'calendar.earlier':
200
+ if not day_mode:
201
+ m -= 1
202
+ if m < 1:
203
+ m = 12
204
+ y -= 1
205
+ cursor_day = min(cursor_day, _days_in_month(y, m))
206
+ else:
207
+ cursor_day -= 1
208
+ if cursor_day < 1:
209
+ m -= 1
210
+ if m < 1:
211
+ m = 12
212
+ y -= 1
213
+ cursor_day = _days_in_month(y, m)
214
+
215
+ elif act == 'calendar.up':
216
+ if not day_mode:
217
+ y -= 1
218
+ cursor_day = min(cursor_day, _days_in_month(y, m))
219
+ else:
220
+ cursor_day -= 7
221
+ if cursor_day < 1:
222
+ m -= 1
223
+ if m < 1:
224
+ m = 12
225
+ y -= 1
226
+ # Wraps into the last day of the previous month
227
+ cursor_day = _days_in_month(y, m)
228
+
229
+ elif act == 'calendar.down':
230
+ if not day_mode:
231
+ y += 1
232
+ cursor_day = min(cursor_day, _days_in_month(y, m))
233
+ else:
234
+ cursor_day += 7
235
+ if cursor_day > _days_in_month(y, m):
236
+ m += 1
237
+ if m > 12:
238
+ m = 1
239
+ y += 1
240
+ # Wraps into the first day of the next month
241
+ cursor_day = 1
242
+
243
+ elif act == 'calendar.manual':
244
+ w.clear()
245
+ manual = text("Enter date (YYYY-MM-DD):", default=f"{y:04d}-{m:02d}-{cursor_day:02d}")
246
+ if manual:
247
+ parsed = _parse_date(manual)
248
+ if parsed:
249
+ y, m, d = parsed
250
+ cursor_day = d
251
+ ui.clear_screen()
252
+ enable_mouse() # text() turns it off on its way out
253
+ w.anchor_reset()
254
+
255
+ elif key.isdigit():
256
+ # Accumulate a leading 1/2/3 into a two-digit day (10-31),
257
+ # otherwise jump straight to the single-digit day.
258
+ dm = _days_in_month(y, m)
259
+ if _digit_buf in ("1", "2", "3") and 1 <= int(_digit_buf + key) <= dm:
260
+ cursor_day = int(_digit_buf + key)
261
+ _digit_buf = ""
262
+ else:
263
+ d1 = int(key)
264
+ _digit_buf = key if d1 in (1, 2, 3) else ""
265
+ if 1 <= d1 <= dm:
266
+ cursor_day = d1
267
+ else:
268
+ _digit_buf = ""
269
+
270
+ _render()
271
+
272
+ finally:
273
+ disable_mouse()
274
+ _restore_term_attrs(fd, old)
275
+ w.clear()
276
+
277
+ return result
278
+
279
+
280
+ def datetime_edit(message: str = "Edit date and time:", initial: str = "") -> str | None:
281
+ """
282
+ Combined single-screen date + time editor.
283
+
284
+ Date section: calendar grid (TAB toggles month/year ↔ day navigation).
285
+ Time section: HH:MM:SS.ms fields (TAB advances field).
286
+ TAB from date-day-mode → time section; TAB from last time field → date.
287
+ ENTER saves from any position. Returns ISO 8601 string or None if cancelled.
288
+ """
289
+ date_str, time_str = dtp.split_stamp(initial)
290
+
291
+ parsed = _parse_date(date_str) if date_str else None
292
+ if parsed:
293
+ year, month, cursor_day = parsed
294
+ else:
295
+ _today = datetime.date.today()
296
+ year, month, cursor_day = _today.year, _today.month, _today.day
297
+
298
+ day_mode = False
299
+
300
+ def _digits_only(s: str) -> str:
301
+ """Strip everything from the first non-digit onward, keeping just the leading digit run."""
302
+ return re.sub(r'\D.*', '', s) # keep only leading digit run
303
+
304
+ t_parts = time_str.split(':') if time_str else []
305
+ _h = _digits_only(t_parts[0]) if t_parts else ""
306
+ _mi = _digits_only(t_parts[1]) if len(t_parts) > 1 else ""
307
+ if len(t_parts) > 2:
308
+ _sp = t_parts[2].split('.')
309
+ _s = _digits_only(_sp[0]) if _sp else ""
310
+ _ms = _digits_only(_sp[1]) if len(_sp) > 1 else ""
311
+ else:
312
+ _s, _ms = "", ""
313
+
314
+ tfields = {
315
+ 'hours': list((_h or "00")[-2:].zfill(2)),
316
+ 'minutes': list((_mi or "00")[-2:].zfill(2)),
317
+ 'seconds': list((_s or "00")[-2:].zfill(2)),
318
+ 'millis': list((_ms or "000")[-3:].zfill(3)),
319
+ }
320
+ torder = ['hours', 'minutes', 'seconds', 'millis']
321
+ tmaxlen = {'hours': 2, 'minutes': 2, 'seconds': 2, 'millis': 3}
322
+ tcursor = 0
323
+ tpos = {k: len(tfields[k]) for k in torder}
324
+
325
+ section = 'date'
326
+
327
+ fd = sys.stdin.fileno()
328
+ old = _get_term_attrs(fd)
329
+ w = _Widget(fd)
330
+ _hint_cells: dict = {} # clickable hint keys, filled by append_chrome
331
+
332
+ def _render():
333
+ lines = []
334
+
335
+ lines.append(f" {C.DIM}{message}{C.RESET}")
336
+ lines.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
337
+
338
+ # Date section
339
+ month_name = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
340
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][month]
341
+ dpfx = C.BOLD if section == 'date' else C.DIM
342
+ lines.append(f" {dpfx}{month_name} {year}{C.RESET}")
343
+ lines.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
344
+ lines.append(" Mo Tu We Th Fr Sa Su")
345
+
346
+ for week in cal.monthcalendar(year, month):
347
+ parts = []
348
+ for day in week:
349
+ if day == 0:
350
+ parts.append(" ")
351
+ elif day == cursor_day and section == 'date':
352
+ style = f"{C.ACCENT}{C.BOLD}" if day_mode else C.BOLD
353
+ parts.append(f"{style}{day:2d}{C.RESET} ")
354
+ else:
355
+ parts.append(f"{day:2d} ")
356
+ lines.append(f" {''.join(parts)}")
357
+
358
+ lines.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
359
+
360
+ # Time section
361
+ tpfx = C.BOLD if section == 'time' else C.DIM
362
+ row = f" {tpfx}Time{C.RESET} "
363
+ for i, field in enumerate(torder):
364
+ val = "".join(tfields[field])
365
+ pos = tpos[field]
366
+ if section == 'time' and i == tcursor:
367
+ cell = block_cursor(val, pos)
368
+ else:
369
+ cell = f"{C.DIM}{val.ljust(tmaxlen[field], '0')}{C.RESET}"
370
+ row += cell
371
+ if i == 0: row += ":"
372
+ elif i == 1: row += ":"
373
+ elif i == 2: row += "."
374
+ lines.append(row)
375
+
376
+ lines.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
377
+
378
+ h = ""
379
+ if section == 'date':
380
+ if not day_mode:
381
+ h = [("←→", "month"), ("↑↓", "year"), ("tab/⇧tab", "field"),
382
+ ("↵", "save"), ("esc", "back"), (keys.label("list.quit"), "quit app")]
383
+ else:
384
+ h = [("←→↑↓", "navigate"), ("tab/⇧tab", "field"), ("↵", "save"),
385
+ ("esc", "back"), (keys.label("list.quit"), "quit app")]
386
+ elif section == 'time':
387
+ h = [("←→", "cursor"), ("tab/⇧tab", "field"), ("↵", "save"),
388
+ ("esc", "back"), (keys.label("list.quit"), "quit app")]
389
+
390
+ append_chrome(lines, _with_toggle_hint(h), _hint_cells)
391
+ w.render(lines)
392
+
393
+ def _build_result() -> str:
394
+ """Assemble the ISO datetime string from date + time fields, omitting a zero time-of-day or trailing zero millis."""
395
+ h = "".join(tfields['hours']).zfill(2)
396
+ mi = "".join(tfields['minutes']).zfill(2)
397
+ s = "".join(tfields['seconds']).zfill(2)
398
+ ms = "".join(tfields['millis']).zfill(3)
399
+ hms = f"{h}:{mi}:{s}"
400
+ date_part = f"{year:04d}-{month:02d}-{cursor_day:02d}"
401
+ if hms == "00:00:00":
402
+ return date_part
403
+ if ms == "000":
404
+ return f"{date_part}T{hms}"
405
+ return f"{date_part}T{hms}.{ms}"
406
+
407
+ result = None
408
+ try:
409
+ _set_raw(fd)
410
+ enable_mouse() # so the hint keys below can be clicked
411
+ screen_takeover_next() # paint over the previous screen, no flash
412
+ _render()
413
+
414
+ while True:
415
+ if ui.consume_resize():
416
+ ui.clear_screen()
417
+ w.anchor_reset()
418
+ _render()
419
+ continue
420
+
421
+ if not _wait_for_keypress(0.05):
422
+ continue
423
+
424
+ key = _read_key(fd)
425
+ # A transport key, a click on the now-playing box, or a click on one of our
426
+ # own hint keys, handled the same way on every screen.
427
+ _ch = consume_chrome(key, _hint_cells)
428
+ if _ch is CHROME_HANDLED:
429
+ continue
430
+ if _ch is CHROME_REDRAW:
431
+ w.anchor_reset(); _render(); continue
432
+ if _ch is not None:
433
+ key = _ch
434
+
435
+ if chrome.is_mode_toggle(key):
436
+ return MODE_TOGGLE # type: ignore[return-value]
437
+ if key in ('ESC', 'CTRL_C'):
438
+ break
439
+ if keys.pressed(key, 'list.quit'):
440
+ raise QuitToTerminal() # q quits the app; it never just leaves a widget
441
+
442
+ if key == 'ENTER':
443
+ result = _build_result()
444
+ break
445
+
446
+ elif key == 'TAB':
447
+ if section == 'date':
448
+ if not day_mode:
449
+ day_mode = True
450
+ else:
451
+ day_mode = False
452
+ section = 'time'
453
+ tcursor = 0
454
+ elif section == 'time':
455
+ if tcursor < len(torder) - 1:
456
+ tcursor += 1
457
+ else:
458
+ # Cycle back to the date section (docstring contract),
459
+ # starting at the month/year view.
460
+ section = 'date'
461
+ day_mode = False
462
+ tcursor = 0
463
+
464
+ elif key == 'BACKTAB':
465
+ # The same chain backwards: month/year ← day grid ← time fields.
466
+ if section == 'date':
467
+ if day_mode:
468
+ day_mode = False
469
+ else:
470
+ section = 'time'
471
+ tcursor = len(torder) - 1
472
+ else:
473
+ if tcursor > 0:
474
+ tcursor -= 1
475
+ else:
476
+ section = 'date'
477
+ day_mode = True
478
+
479
+ elif section == 'date':
480
+ if key == 'RIGHT':
481
+ if not day_mode:
482
+ month += 1
483
+ if month > 12: month, year = 1, year + 1
484
+ cursor_day = min(cursor_day, _days_in_month(year, month))
485
+ else:
486
+ cursor_day += 1
487
+ if cursor_day > _days_in_month(year, month):
488
+ month += 1
489
+ if month > 12: month, year = 1, year + 1
490
+ cursor_day = 1
491
+ elif key == 'LEFT':
492
+ if not day_mode:
493
+ month -= 1
494
+ if month < 1: month, year = 12, year - 1
495
+ cursor_day = min(cursor_day, _days_in_month(year, month))
496
+ else:
497
+ cursor_day -= 1
498
+ if cursor_day < 1:
499
+ month -= 1
500
+ if month < 1: month, year = 12, year - 1
501
+ cursor_day = _days_in_month(year, month)
502
+ elif key == 'UP':
503
+ if not day_mode:
504
+ year -= 1
505
+ cursor_day = min(cursor_day, _days_in_month(year, month))
506
+ else:
507
+ cursor_day -= 7
508
+ if cursor_day < 1:
509
+ month -= 1
510
+ if month < 1: month, year = 12, year - 1
511
+ cursor_day = _days_in_month(year, month)
512
+ elif key == 'DOWN':
513
+ if not day_mode:
514
+ year += 1
515
+ cursor_day = min(cursor_day, _days_in_month(year, month))
516
+ else:
517
+ cursor_day += 7
518
+ if cursor_day > _days_in_month(year, month):
519
+ month += 1
520
+ if month > 12: month, year = 1, year + 1
521
+ cursor_day = 1
522
+
523
+ else: # time section
524
+ cur_f = torder[tcursor]
525
+ buf = tfields[cur_f]
526
+ pos = tpos[cur_f]
527
+ maxl = tmaxlen[cur_f]
528
+
529
+ if key == 'BACKSPACE':
530
+ if pos > 0:
531
+ buf.pop(pos - 1)
532
+ tpos[cur_f] = pos - 1
533
+ elif key == 'DELETE':
534
+ if pos < len(buf):
535
+ buf.pop(pos)
536
+ elif key == 'LEFT':
537
+ tpos[cur_f] = max(0, pos - 1)
538
+ elif key == 'RIGHT':
539
+ tpos[cur_f] = min(len(buf), pos + 1)
540
+ elif key == 'HOME':
541
+ tpos[cur_f] = 0
542
+ elif key == 'END':
543
+ tpos[cur_f] = len(buf)
544
+ elif key.isdigit() and len(buf) < maxl:
545
+ buf.insert(pos, key)
546
+ tpos[cur_f] = pos + 1
547
+
548
+ _render()
549
+
550
+ finally:
551
+ disable_mouse()
552
+ _restore_term_attrs(fd, old)
553
+ w.clear()
554
+
555
+ return result
@@ -0,0 +1,143 @@
1
+ """Settings → Key bindings: every action in backbone.keys, one row each, with
2
+ its keys. ↵ adds a key (press it), and the page's own keys remove one, reset
3
+ one, or reset everything. Changes save at once, so every hint follows."""
4
+ from __future__ import annotations
5
+ import sys
6
+
7
+ from backbone import keys, ui
8
+ from backbone.log import log
9
+ from backbone.prompt.core import Choice, Column, _read_key, rounded_header, separator
10
+ from backbone.ui import Colors as C
11
+
12
+ keys.define("keys_editor", "Key bindings page", [
13
+ ("remove", ("d", "DELETE", "BACKSPACE"), "remove a key from the action"),
14
+ ("reset", ("r",), "put the action back to its default keys"),
15
+ ("reset_all", ("R",), "put every action back to its default keys"),
16
+ ])
17
+
18
+ _COLUMNS = [
19
+ Column(style='primary'), # what it does
20
+ Column(), # its keys: grey, or accent once changed
21
+ Column(style='dynamic-dim', flex=True), # default / changed
22
+ ]
23
+
24
+
25
+ def _row(a: keys.Action) -> Choice:
26
+ shown = keys.label(a.id) or "unbound"
27
+ changed = keys.changed(a.id)
28
+ return Choice(title=a.label, value=a.id,
29
+ cells=[a.label, [(shown, 'accent' if changed else 'dynamic-dim')],
30
+ "changed" if changed else ""])
31
+
32
+
33
+ def _choices() -> list:
34
+ out: list = []
35
+ for scope, title in keys.scopes():
36
+ if scope == "keys_editor":
37
+ continue # listed last, below
38
+ out.append(separator(title))
39
+ out += [_row(a) for a in keys.actions(scope)]
40
+ out.append(separator("This page"))
41
+ out += [_row(a) for a in keys.actions("keys_editor")]
42
+ return out
43
+
44
+
45
+ def _capture(what: str) -> str | None:
46
+ """The next key pressed, for `what`; None on Esc."""
47
+ from backbone.terminal_input import raw_mode
48
+ ui.clear_screen()
49
+ pad = " " * ui.MARGIN_H
50
+ sys.stdout.write(f"\n{pad}{C.BOLD}Press the new key for: {what}{C.RESET}\n"
51
+ f"{pad}{C.DIM}esc cancels{C.RESET}\n")
52
+ sys.stdout.flush()
53
+ with raw_mode(sys.stdin):
54
+ while True:
55
+ key = _read_key(sys.stdin.fileno())
56
+ if key == 'ESC':
57
+ return None
58
+ if keys.bindable(key):
59
+ return key
60
+
61
+
62
+ def _title(aid: str) -> str:
63
+ a = next(x for x in keys.actions() if x.id == aid)
64
+ scope_title = dict(keys.scopes()).get(a.scope, a.scope)
65
+ return f"{a.label} ({scope_title})"
66
+
67
+
68
+ def _add_key(aid: str) -> None:
69
+ from backbone.prompt.lists import select
70
+ key = _capture(_title(aid))
71
+ if key is None:
72
+ return
73
+ shown = keys.glyph(key)
74
+ if key in keys.of(aid):
75
+ ui.show_status(f"{shown} is already one of its keys.")
76
+ return
77
+ others = keys.conflicts(aid, key)
78
+ if others:
79
+ names = "; ".join(_title(o) for o in others)
80
+ pick = select(f"{shown} is already used for {names}.",
81
+ [Choice("Move it to this action", value="move"), Choice("Cancel", value=None)])
82
+ if pick != "move":
83
+ return
84
+ for o in others:
85
+ keys.bind(o, tuple(k for k in keys.of(o) if k != key))
86
+ keys.bind(aid, keys.of(aid) + (key,))
87
+ log.info("key binding %s += %r", aid, key)
88
+ ui.show_status(f"{shown} now: {_title(aid)}")
89
+
90
+
91
+ def _remove_key(aid: str) -> None:
92
+ from backbone.prompt.lists import select
93
+ bound = keys.of(aid)
94
+ if not bound:
95
+ return
96
+ key = bound[0] if len(bound) == 1 else select(
97
+ f"Remove which key from {_title(aid)}?",
98
+ [Choice(keys.glyph(k), value=k) for k in bound])
99
+ if key is None:
100
+ return
101
+ keys.bind(aid, tuple(k for k in bound if k != key))
102
+ ui.show_status(f"{keys.glyph(key)} removed" + ("" if len(bound) > 1 else ": the action has no key now"))
103
+
104
+
105
+ def keys_editor() -> None:
106
+ """The Key bindings page. Returns when backed out of."""
107
+ from backbone.prompt.lists import ListPlace, select
108
+ place = ListPlace()
109
+ while True:
110
+ changed = sum(keys.changed(a.id) for a in keys.actions())
111
+ done: dict = {}
112
+
113
+ def _act(name):
114
+ def run(aid):
115
+ done['act'], done['aid'] = name, aid
116
+ return aid # end the list; the loop redraws it
117
+ return run
118
+
119
+ row_actions, hints = {}, {}
120
+ for name in ("remove", "reset", "reset_all"):
121
+ aid = f"keys_editor.{name}"
122
+ for k in keys.of(aid):
123
+ row_actions[k] = _act(name)
124
+ hints[keys.label(aid)] = {"remove": "remove a key", "reset": "reset",
125
+ "reset_all": "reset all"}[name]
126
+
127
+ choice = select("", _choices(), columns=_COLUMNS, place=place,
128
+ header=lambda: rounded_header("Key bindings", "",
129
+ f"{changed} changed" if changed else "all default"),
130
+ extra_hints={"↵": "add a key"},
131
+ row_actions=row_actions, row_action_hints=hints)
132
+ if choice is None:
133
+ return
134
+ act = done.get('act')
135
+ if act == "remove":
136
+ _remove_key(choice)
137
+ elif act == "reset":
138
+ keys.reset(choice)
139
+ elif act == "reset_all":
140
+ keys.reset()
141
+ ui.show_status("Every key is back to its default.")
142
+ else:
143
+ _add_key(choice)