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