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,581 @@
|
|
|
1
|
+
"""Small value editors: fraction pairs, times, numbers and ratings."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import sys
|
|
4
|
+
from backbone.prompt.core import (
|
|
5
|
+
_get_term_attrs, _set_raw, _restore_term_attrs, _wait_for_keypress, block_cursor,
|
|
6
|
+
_read_key, _Widget, screen_takeover_next,
|
|
7
|
+
)
|
|
8
|
+
from backbone import keys, ui
|
|
9
|
+
from backbone.nav import QuitToTerminal
|
|
10
|
+
from backbone.prompt import chrome
|
|
11
|
+
from backbone.prompt.chrome import CHROME_HANDLED, CHROME_REDRAW, MODE_TOGGLE, _with_toggle_hint, append_chrome, consume_chrome, disable_mouse, enable_mouse
|
|
12
|
+
from backbone.prompt.core import C
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _frac_result(buffers: dict, varies: set) -> dict:
|
|
16
|
+
"""Collect the fraction editor's buffers, mapping an untouched *varying*
|
|
17
|
+
field to None so the caller keeps each file's existing value for it."""
|
|
18
|
+
out = {}
|
|
19
|
+
for field in ('current', 'total'):
|
|
20
|
+
text = "".join(buffers[field])
|
|
21
|
+
out[field] = None if (not text and field in varies) else text
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fraction_edit(message: str = "Edit metadata pair:",
|
|
26
|
+
tag: str = "TRCK", value: str = "",
|
|
27
|
+
varies: object = ()) -> dict | None:
|
|
28
|
+
"""
|
|
29
|
+
In-place editor for an isolated single tag's current/total values.
|
|
30
|
+
Accepts any text in either half.
|
|
31
|
+
|
|
32
|
+
``varies`` names the fields ('current' / 'total') that differ across a bulk
|
|
33
|
+
selection. Those open blank, render as a dim ``──`` placeholder, and come
|
|
34
|
+
back as ``None`` if left untouched (meaning "keep each file's own value"),
|
|
35
|
+
so you can set the half the files share without flattening the half they
|
|
36
|
+
don't. Typing into such a field turns it into a real value for everything.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Dict with keys: {'current', 'total'} (a value may be None when it
|
|
40
|
+
varies and was left alone), or None if cancelled
|
|
41
|
+
"""
|
|
42
|
+
varies = {str(v) for v in (varies or ())}
|
|
43
|
+
tag_config = {
|
|
44
|
+
"TRCK": ("Track", "of"),
|
|
45
|
+
"TPOS": ("Disc", "of"),
|
|
46
|
+
"MVIN": ("Movement", "of"),
|
|
47
|
+
}
|
|
48
|
+
lbl_idx, lbl_tot = tag_config.get(tag.upper(), ("Index", "of"))
|
|
49
|
+
|
|
50
|
+
# Extract baseline values from the value string (e.g., "3.5/12" -> current="3.5", total="12")
|
|
51
|
+
parts = str(value).split('/') if '/' in str(value) else str(value).split('⁄') if '⁄' in str(value) else [value, ""] if value else ["", ""]
|
|
52
|
+
curr_val = parts[0].strip()
|
|
53
|
+
tot_val = parts[1].strip() if len(parts) > 1 else ""
|
|
54
|
+
# A field that varies has no single value to show, so it starts empty and
|
|
55
|
+
# picks up the dim placeholder below.
|
|
56
|
+
if 'current' in varies:
|
|
57
|
+
curr_val = ""
|
|
58
|
+
if 'total' in varies:
|
|
59
|
+
tot_val = ""
|
|
60
|
+
|
|
61
|
+
field_order = ['current', 'total']
|
|
62
|
+
field_labels = {'current': lbl_idx, 'total': lbl_tot}
|
|
63
|
+
|
|
64
|
+
cursor_field = 0
|
|
65
|
+
edit_buffers = {
|
|
66
|
+
'current': list(curr_val),
|
|
67
|
+
'total': list(tot_val)
|
|
68
|
+
}
|
|
69
|
+
edit_positions = {k: len(edit_buffers[k]) for k in field_order}
|
|
70
|
+
|
|
71
|
+
fd = sys.stdin.fileno()
|
|
72
|
+
old = _get_term_attrs(fd)
|
|
73
|
+
w = _Widget(fd)
|
|
74
|
+
_hint_cells: dict = {} # clickable hint keys, filled by append_chrome
|
|
75
|
+
|
|
76
|
+
def _render():
|
|
77
|
+
lines = []
|
|
78
|
+
|
|
79
|
+
lines.append(f" {C.DIM}{message}{C.RESET}")
|
|
80
|
+
lines.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
|
|
81
|
+
|
|
82
|
+
row = " "
|
|
83
|
+
for i, field in enumerate(field_order):
|
|
84
|
+
if i > 0:
|
|
85
|
+
row += " "
|
|
86
|
+
|
|
87
|
+
label = field_labels[field]
|
|
88
|
+
val_str = "".join(edit_buffers[field])
|
|
89
|
+
|
|
90
|
+
if i == cursor_field:
|
|
91
|
+
row += f"{label} {block_cursor(val_str, edit_positions[field])}"
|
|
92
|
+
else:
|
|
93
|
+
if not val_str:
|
|
94
|
+
row += f"{C.DIM}{label} ──{C.RESET}"
|
|
95
|
+
else:
|
|
96
|
+
row += f"{label} {val_str}"
|
|
97
|
+
if field in varies and not val_str:
|
|
98
|
+
row += f" {C.DIM}(varies){C.RESET}"
|
|
99
|
+
|
|
100
|
+
lines.append(row)
|
|
101
|
+
lines.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
|
|
102
|
+
|
|
103
|
+
# No q: these fields take text, so q is a letter here.
|
|
104
|
+
append_chrome(lines, _with_toggle_hint(
|
|
105
|
+
[("↵", "save"), ("tab/⇧tab", "field"), ("esc", "back")]), _hint_cells)
|
|
106
|
+
w.render(lines)
|
|
107
|
+
|
|
108
|
+
result = None
|
|
109
|
+
try:
|
|
110
|
+
_set_raw(fd)
|
|
111
|
+
enable_mouse() # so the hint keys below can be clicked
|
|
112
|
+
screen_takeover_next() # paint over the previous screen, no flash
|
|
113
|
+
_render()
|
|
114
|
+
|
|
115
|
+
while True:
|
|
116
|
+
if ui.consume_resize():
|
|
117
|
+
ui.clear_screen()
|
|
118
|
+
w.anchor_reset()
|
|
119
|
+
_render()
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if not _wait_for_keypress(0.05):
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
key = _read_key(fd)
|
|
126
|
+
# A transport key, a click on the now-playing box, or a click on one of our
|
|
127
|
+
# own hint keys, handled the same way on every screen.
|
|
128
|
+
_ch = consume_chrome(key, _hint_cells)
|
|
129
|
+
if _ch is CHROME_HANDLED:
|
|
130
|
+
continue
|
|
131
|
+
if _ch is CHROME_REDRAW:
|
|
132
|
+
w.anchor_reset(); _render(); continue
|
|
133
|
+
if _ch is not None:
|
|
134
|
+
key = _ch
|
|
135
|
+
if chrome.is_mode_toggle(key):
|
|
136
|
+
return MODE_TOGGLE # type: ignore[return-value]
|
|
137
|
+
current_field = field_order[cursor_field]
|
|
138
|
+
buf = edit_buffers[current_field]
|
|
139
|
+
pos = edit_positions[current_field]
|
|
140
|
+
|
|
141
|
+
if key == 'ENTER':
|
|
142
|
+
result = _frac_result(edit_buffers, varies)
|
|
143
|
+
break
|
|
144
|
+
elif key in ('ESC', 'CTRL_C'): # Ctrl-C cancels, as in every widget
|
|
145
|
+
break
|
|
146
|
+
elif key in ('TAB', 'BACKTAB'):
|
|
147
|
+
# Shift+Tab is Tab in reverse, on every screen that has fields.
|
|
148
|
+
_step = -1 if key == 'BACKTAB' else 1
|
|
149
|
+
cursor_field = (cursor_field + _step) % len(field_order)
|
|
150
|
+
elif key == 'BACKSPACE':
|
|
151
|
+
if pos > 0:
|
|
152
|
+
buf.pop(pos - 1)
|
|
153
|
+
edit_positions[current_field] = pos - 1
|
|
154
|
+
elif key == 'DELETE':
|
|
155
|
+
if pos < len(buf):
|
|
156
|
+
buf.pop(pos)
|
|
157
|
+
elif key == 'LEFT':
|
|
158
|
+
edit_positions[current_field] = max(0, pos - 1)
|
|
159
|
+
elif key == 'RIGHT':
|
|
160
|
+
edit_positions[current_field] = min(len(buf), pos + 1)
|
|
161
|
+
elif key == 'SPACE' or (len(key) == 1 and (key.isalnum() or key in ".-")):
|
|
162
|
+
buf.insert(pos, ' ' if key == 'SPACE' else key)
|
|
163
|
+
edit_positions[current_field] = pos + 1
|
|
164
|
+
|
|
165
|
+
_render()
|
|
166
|
+
|
|
167
|
+
finally:
|
|
168
|
+
disable_mouse()
|
|
169
|
+
_restore_term_attrs(fd, old)
|
|
170
|
+
w.clear()
|
|
171
|
+
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def time_edit(message: str = "Edit time:", initial: str = "00:00:00") -> str | None:
|
|
176
|
+
"""
|
|
177
|
+
In-place editor for time input (HH:MM:SS).
|
|
178
|
+
Supports milliseconds and auto-validation.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
message: Prompt label
|
|
182
|
+
initial: Initial time (HH:MM:SS or HH:MM:SS.mmm)
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Formatted time string or None if cancelled
|
|
186
|
+
"""
|
|
187
|
+
parts = initial.split(':')
|
|
188
|
+
hours = parts[0] if parts and parts[0] else "00"
|
|
189
|
+
minutes = parts[1] if len(parts) > 1 and parts[1] else "00"
|
|
190
|
+
|
|
191
|
+
if len(parts) > 2:
|
|
192
|
+
sec_parts = parts[2].split('.')
|
|
193
|
+
seconds = sec_parts[0] if sec_parts else "00"
|
|
194
|
+
millis = sec_parts[1] if len(sec_parts) > 1 else "000"
|
|
195
|
+
else:
|
|
196
|
+
seconds = "00"
|
|
197
|
+
millis = "000"
|
|
198
|
+
|
|
199
|
+
fields = {
|
|
200
|
+
'hours': list(hours[-2:].zfill(2)),
|
|
201
|
+
'minutes': list(minutes[-2:].zfill(2)),
|
|
202
|
+
'seconds': list(seconds[-2:].zfill(2)),
|
|
203
|
+
'millis': list(millis[-3:].zfill(3)),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
field_order = ['hours', 'minutes', 'seconds', 'millis']
|
|
207
|
+
field_maxlen = {
|
|
208
|
+
'hours': 2,
|
|
209
|
+
'minutes': 2,
|
|
210
|
+
'seconds': 2,
|
|
211
|
+
'millis': 3,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
cursor_field = 0
|
|
215
|
+
positions = {k: len(fields[k]) for k in field_order}
|
|
216
|
+
|
|
217
|
+
fd = sys.stdin.fileno()
|
|
218
|
+
old = _get_term_attrs(fd)
|
|
219
|
+
w = _Widget(fd)
|
|
220
|
+
_hint_cells: dict = {} # clickable hint keys, filled by append_chrome
|
|
221
|
+
|
|
222
|
+
def _validate_time() -> bool:
|
|
223
|
+
"""True if the current H/M/S fields form a valid time."""
|
|
224
|
+
try:
|
|
225
|
+
h = int("".join(fields['hours']) or "0")
|
|
226
|
+
m = int("".join(fields['minutes']) or "0")
|
|
227
|
+
s = int("".join(fields['seconds']) or "0")
|
|
228
|
+
return 0 <= h < 24 and 0 <= m < 60 and 0 <= s < 60
|
|
229
|
+
except ValueError:
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
def _render():
|
|
233
|
+
lines = []
|
|
234
|
+
|
|
235
|
+
lines.append(f" {C.DIM}{message}{C.RESET}")
|
|
236
|
+
lines.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
|
|
237
|
+
|
|
238
|
+
row = " "
|
|
239
|
+
for i, field in enumerate(field_order):
|
|
240
|
+
value = "".join(fields[field])
|
|
241
|
+
pos = positions[field]
|
|
242
|
+
|
|
243
|
+
if i == cursor_field:
|
|
244
|
+
display = block_cursor(value, pos)
|
|
245
|
+
else:
|
|
246
|
+
display = value.ljust(field_maxlen[field], '0')
|
|
247
|
+
|
|
248
|
+
row += display
|
|
249
|
+
|
|
250
|
+
if i == 0:
|
|
251
|
+
row += ":"
|
|
252
|
+
elif i == 1:
|
|
253
|
+
row += ":"
|
|
254
|
+
elif i == 2:
|
|
255
|
+
row += "."
|
|
256
|
+
|
|
257
|
+
lines.append(row)
|
|
258
|
+
lines.append(f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}")
|
|
259
|
+
append_chrome(lines, _with_toggle_hint(
|
|
260
|
+
[('↵', 'save'), ('tab/⇧tab', 'field'),
|
|
261
|
+
('esc', 'back'), (keys.label('list.quit'), 'quit app')]), _hint_cells)
|
|
262
|
+
w.render(lines)
|
|
263
|
+
|
|
264
|
+
result = None
|
|
265
|
+
try:
|
|
266
|
+
_set_raw(fd)
|
|
267
|
+
enable_mouse() # so the hint keys below can be clicked
|
|
268
|
+
screen_takeover_next() # paint over the previous screen, no flash
|
|
269
|
+
_render()
|
|
270
|
+
|
|
271
|
+
while True:
|
|
272
|
+
if ui.consume_resize():
|
|
273
|
+
ui.clear_screen()
|
|
274
|
+
w.anchor_reset()
|
|
275
|
+
_render()
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
if not _wait_for_keypress(0.05):
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
key = _read_key(fd)
|
|
282
|
+
# A transport key, a click on the now-playing box, or a click on one of our
|
|
283
|
+
# own hint keys, handled the same way on every screen.
|
|
284
|
+
_ch = consume_chrome(key, _hint_cells)
|
|
285
|
+
if _ch is CHROME_HANDLED:
|
|
286
|
+
continue
|
|
287
|
+
if _ch is CHROME_REDRAW:
|
|
288
|
+
w.anchor_reset(); _render(); continue
|
|
289
|
+
if _ch is not None:
|
|
290
|
+
key = _ch
|
|
291
|
+
if chrome.is_mode_toggle(key):
|
|
292
|
+
return MODE_TOGGLE # type: ignore[return-value]
|
|
293
|
+
current_field = field_order[cursor_field]
|
|
294
|
+
buf = fields[current_field]
|
|
295
|
+
pos = positions[current_field]
|
|
296
|
+
max_len = field_maxlen[current_field]
|
|
297
|
+
|
|
298
|
+
if key == 'ENTER':
|
|
299
|
+
if _validate_time():
|
|
300
|
+
h = "".join(fields['hours']).zfill(2)
|
|
301
|
+
m = "".join(fields['minutes']).zfill(2)
|
|
302
|
+
s = "".join(fields['seconds']).zfill(2)
|
|
303
|
+
ms = "".join(fields['millis']).zfill(3)
|
|
304
|
+
result = f"{h}:{m}:{s}.{ms}"
|
|
305
|
+
break
|
|
306
|
+
else:
|
|
307
|
+
ui.show_status("Invalid time (need hours < 24, minutes/seconds < 60)")
|
|
308
|
+
elif key in ('ESC', 'CTRL_C'): # Ctrl-C cancels, as in every widget
|
|
309
|
+
break
|
|
310
|
+
elif keys.pressed(key, 'list.quit'):
|
|
311
|
+
raise QuitToTerminal() # q quits the app; it never just leaves a widget
|
|
312
|
+
elif key in ('TAB', 'BACKTAB'):
|
|
313
|
+
# Shift+Tab is Tab in reverse, on every screen that has fields.
|
|
314
|
+
_step = -1 if key == 'BACKTAB' else 1
|
|
315
|
+
cursor_field = (cursor_field + _step) % len(field_order)
|
|
316
|
+
elif key == 'BACKSPACE':
|
|
317
|
+
if pos > 0:
|
|
318
|
+
buf.pop(pos - 1)
|
|
319
|
+
positions[current_field] = pos - 1
|
|
320
|
+
elif key == 'DELETE':
|
|
321
|
+
if pos < len(buf):
|
|
322
|
+
buf.pop(pos)
|
|
323
|
+
elif key == 'LEFT':
|
|
324
|
+
positions[current_field] = max(0, pos - 1)
|
|
325
|
+
elif key == 'RIGHT':
|
|
326
|
+
positions[current_field] = min(len(buf), pos + 1)
|
|
327
|
+
elif key == 'HOME':
|
|
328
|
+
positions[current_field] = 0
|
|
329
|
+
elif key == 'END':
|
|
330
|
+
positions[current_field] = len(buf)
|
|
331
|
+
elif key.isdigit() and len(buf) < max_len:
|
|
332
|
+
buf.insert(pos, key)
|
|
333
|
+
positions[current_field] = pos + 1
|
|
334
|
+
|
|
335
|
+
_render()
|
|
336
|
+
|
|
337
|
+
finally:
|
|
338
|
+
disable_mouse()
|
|
339
|
+
_restore_term_attrs(fd, old)
|
|
340
|
+
w.clear()
|
|
341
|
+
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def number_edit(message: str = "Edit number:", *, value: int = 0,
|
|
346
|
+
minimum: int = 0, maximum: int | None = None, unit: str = ""):
|
|
347
|
+
"""Bounded-integer spinner (TBPM / TLEN / TDLY / play counts, …).
|
|
348
|
+
|
|
349
|
+
↑/↓ step ±1, PgUp/PgDn ±10, type digits to enter a value directly,
|
|
350
|
+
Backspace deletes a digit, Home clamps to the minimum. Returns the chosen
|
|
351
|
+
int, ``None`` on cancel, or :data:`MODE_TOGGLE` when Ctrl-T flips to the raw
|
|
352
|
+
text field (only when the caller enabled the toggle).
|
|
353
|
+
"""
|
|
354
|
+
def _clamp(n: int) -> int:
|
|
355
|
+
n = max(minimum, n)
|
|
356
|
+
if maximum is not None:
|
|
357
|
+
n = min(maximum, n)
|
|
358
|
+
return n
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
value = _clamp(int(str(value).strip() or minimum))
|
|
362
|
+
except ValueError:
|
|
363
|
+
value = minimum
|
|
364
|
+
buf: list[str] = [] # typed digits; empty → show `value`
|
|
365
|
+
|
|
366
|
+
fd = sys.stdin.fileno()
|
|
367
|
+
old = _get_term_attrs(fd)
|
|
368
|
+
w = _Widget(fd)
|
|
369
|
+
_hint_cells: dict = {} # clickable hint keys, filled by append_chrome
|
|
370
|
+
|
|
371
|
+
def _cur() -> int:
|
|
372
|
+
return _clamp(int("".join(buf))) if buf else value
|
|
373
|
+
|
|
374
|
+
def _render():
|
|
375
|
+
# While digits are being typed the block marks the live field; the
|
|
376
|
+
# resting value carries no caret, so the screen isn't blinking at you.
|
|
377
|
+
shown = block_cursor("".join(buf), len(buf)) if buf else str(value)
|
|
378
|
+
unit_s = f" {unit}" if unit else ""
|
|
379
|
+
bounds = f"min {minimum}" + ("" if maximum is None else f", max {maximum}")
|
|
380
|
+
lines = [
|
|
381
|
+
f" {C.DIM}{message}{C.RESET}",
|
|
382
|
+
"",
|
|
383
|
+
f" {C.ACCENT}▸{C.RESET} {C.BOLD}{shown}{C.RESET}{C.DIM}{unit_s}{C.RESET} {C.DIM}({bounds}){C.RESET}",
|
|
384
|
+
]
|
|
385
|
+
append_chrome(lines, _with_toggle_hint(
|
|
386
|
+
[("↑↓", "±1"), ("⇞⇟", "±10"),
|
|
387
|
+
("↵", "save"), ("esc", "back"), (keys.label("list.quit"), "quit app")]),
|
|
388
|
+
_hint_cells, help_key=True)
|
|
389
|
+
w.render(lines)
|
|
390
|
+
|
|
391
|
+
result = None
|
|
392
|
+
try:
|
|
393
|
+
_set_raw(fd)
|
|
394
|
+
enable_mouse() # so the hint keys below can be clicked
|
|
395
|
+
screen_takeover_next() # paint over the previous screen, no flash
|
|
396
|
+
_render()
|
|
397
|
+
while True:
|
|
398
|
+
if ui.consume_resize():
|
|
399
|
+
ui.clear_screen()
|
|
400
|
+
w.anchor_reset()
|
|
401
|
+
_render()
|
|
402
|
+
continue
|
|
403
|
+
if not _wait_for_keypress(0.05):
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
key = _read_key(fd)
|
|
407
|
+
# A transport key, a click on the now-playing box, or a click on one of our
|
|
408
|
+
# own hint keys, handled the same way on every screen.
|
|
409
|
+
_ch = consume_chrome(key, _hint_cells)
|
|
410
|
+
if _ch is CHROME_HANDLED:
|
|
411
|
+
continue
|
|
412
|
+
if _ch is CHROME_REDRAW:
|
|
413
|
+
w.anchor_reset(); _render(); continue
|
|
414
|
+
if _ch is not None:
|
|
415
|
+
key = _ch
|
|
416
|
+
if chrome.is_mode_toggle(key):
|
|
417
|
+
return MODE_TOGGLE
|
|
418
|
+
if key == 'CTRL_C':
|
|
419
|
+
result = None; break
|
|
420
|
+
elif key == 'ENTER':
|
|
421
|
+
result = _cur(); break
|
|
422
|
+
elif key == 'ESC':
|
|
423
|
+
result = None; break
|
|
424
|
+
elif keys.pressed(key, 'list.quit'):
|
|
425
|
+
raise QuitToTerminal() # q quits the app; it never just leaves a widget
|
|
426
|
+
elif key.isdigit():
|
|
427
|
+
if len("".join(buf)) < 12:
|
|
428
|
+
buf.append(key); _render()
|
|
429
|
+
elif key == 'BACKSPACE':
|
|
430
|
+
if buf:
|
|
431
|
+
buf.pop(); _render()
|
|
432
|
+
elif key == 'UP':
|
|
433
|
+
value = _clamp(_cur() + 1); buf.clear(); _render()
|
|
434
|
+
elif key == 'DOWN':
|
|
435
|
+
value = _clamp(_cur() - 1); buf.clear(); _render()
|
|
436
|
+
elif key == 'PGUP':
|
|
437
|
+
value = _clamp(_cur() + 10); buf.clear(); _render()
|
|
438
|
+
elif key == 'PGDN':
|
|
439
|
+
value = _clamp(_cur() - 10); buf.clear(); _render()
|
|
440
|
+
elif key == 'HOME':
|
|
441
|
+
value = minimum; buf.clear(); _render()
|
|
442
|
+
finally:
|
|
443
|
+
disable_mouse()
|
|
444
|
+
_restore_term_attrs(fd, old)
|
|
445
|
+
w.clear()
|
|
446
|
+
|
|
447
|
+
return result
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def rating_edit(message: str = "Rating:", *, stars: int = 0, count: int = 0,
|
|
451
|
+
email: str = "") -> dict | None:
|
|
452
|
+
"""POPM rating form: a 0-5 star rating, a play count, and the rater email.
|
|
453
|
+
|
|
454
|
+
Tab moves between the three fields. On Rating: ←/→ (or 0-5) set the stars.
|
|
455
|
+
On Plays: ↑/↓ ±1, PgUp/PgDn ±10, or type digits. On Rater: type the
|
|
456
|
+
identifier text. Returns ``{'stars', 'count', 'email'}`` or ``None`` on
|
|
457
|
+
cancel. A binary/asset frame, so there is no raw-text toggle.
|
|
458
|
+
"""
|
|
459
|
+
stars = max(0, min(5, int(stars)))
|
|
460
|
+
count = max(0, int(count))
|
|
461
|
+
email = str(email or "")
|
|
462
|
+
field = 0 # 0 = rating, 1 = plays, 2 = rater
|
|
463
|
+
cbuf: list[str] = [] # typed play-count digits
|
|
464
|
+
|
|
465
|
+
fd = sys.stdin.fileno()
|
|
466
|
+
old = _get_term_attrs(fd)
|
|
467
|
+
w = _Widget(fd)
|
|
468
|
+
_hint_cells: dict = {} # clickable hint keys, filled by append_chrome
|
|
469
|
+
|
|
470
|
+
def _count() -> int:
|
|
471
|
+
return int("".join(cbuf)) if cbuf else count
|
|
472
|
+
|
|
473
|
+
def _render():
|
|
474
|
+
filled = f"{C.ACCENT}{'★' * stars}{C.RESET}"
|
|
475
|
+
empty = f"{C.DIM}{'☆' * (5 - stars)}{C.RESET}"
|
|
476
|
+
rlabel = "unrated" if stars == 0 else f"{stars}/5"
|
|
477
|
+
# Plays and Rater take typing but drew nothing to type against: the
|
|
478
|
+
# block shows which field has the keyboard and where the next character
|
|
479
|
+
# lands. Both append at the end, so the block rides there.
|
|
480
|
+
cshown = "".join(cbuf) if cbuf else str(count)
|
|
481
|
+
if field == 1:
|
|
482
|
+
cshown = block_cursor(cshown, len(cshown))
|
|
483
|
+
if field == 2:
|
|
484
|
+
rater = block_cursor(email, len(email))
|
|
485
|
+
elif email:
|
|
486
|
+
rater = email
|
|
487
|
+
else:
|
|
488
|
+
rater = f"{C.DIM}(default){C.RESET}"
|
|
489
|
+
|
|
490
|
+
def _mark(i: int) -> str:
|
|
491
|
+
return f"{C.ACCENT}▸{C.RESET}" if field == i else " "
|
|
492
|
+
|
|
493
|
+
def _lab(i: int, text: str) -> str:
|
|
494
|
+
return f"{C.BOLD}{text}{C.RESET}" if field == i else f"{C.DIM}{text}{C.RESET}"
|
|
495
|
+
|
|
496
|
+
lines = [
|
|
497
|
+
f" {C.DIM}{message}{C.RESET}",
|
|
498
|
+
"",
|
|
499
|
+
f" {_mark(0)} {_lab(0, 'Rating')} {filled}{empty} {C.DIM}{rlabel}{C.RESET}",
|
|
500
|
+
f" {_mark(1)} {_lab(1, 'Plays ')} {cshown}",
|
|
501
|
+
f" {_mark(2)} {_lab(2, 'Rater ')} {rater}",
|
|
502
|
+
]
|
|
503
|
+
# The keys of the focused row: stars, the play count, or (the Rater's
|
|
504
|
+
# e-mail) just typing, where q is a letter, not quit.
|
|
505
|
+
pairs = [("tab/⇧tab", "field")]
|
|
506
|
+
pairs += {0: [("←→", "stars")], 1: [("←→", "±1"), ("⇞⇟", "±10")]}.get(field, [])
|
|
507
|
+
pairs += [("↵", "save"), ("esc", "back")] + ([(keys.label("list.quit"), "quit app")] if field != 2 else [])
|
|
508
|
+
append_chrome(lines, pairs, _hint_cells)
|
|
509
|
+
w.render(lines)
|
|
510
|
+
|
|
511
|
+
result = None
|
|
512
|
+
try:
|
|
513
|
+
_set_raw(fd)
|
|
514
|
+
enable_mouse() # so the hint keys below can be clicked
|
|
515
|
+
screen_takeover_next() # paint over the previous screen, no flash
|
|
516
|
+
_render()
|
|
517
|
+
while True:
|
|
518
|
+
if ui.consume_resize():
|
|
519
|
+
ui.clear_screen()
|
|
520
|
+
w.anchor_reset()
|
|
521
|
+
_render()
|
|
522
|
+
continue
|
|
523
|
+
if not _wait_for_keypress(0.05):
|
|
524
|
+
continue
|
|
525
|
+
|
|
526
|
+
key = _read_key(fd)
|
|
527
|
+
# A transport key, a click on the now-playing box, or a click on one of our
|
|
528
|
+
# own hint keys, handled the same way on every screen.
|
|
529
|
+
_ch = consume_chrome(key, _hint_cells)
|
|
530
|
+
if _ch is CHROME_HANDLED:
|
|
531
|
+
continue
|
|
532
|
+
if _ch is CHROME_REDRAW:
|
|
533
|
+
w.anchor_reset(); _render(); continue
|
|
534
|
+
if _ch is not None:
|
|
535
|
+
key = _ch
|
|
536
|
+
if key == 'CTRL_C':
|
|
537
|
+
result = None; break
|
|
538
|
+
elif key == 'ENTER':
|
|
539
|
+
result = {'stars': stars, 'count': _count(), 'email': email}; break
|
|
540
|
+
elif key == 'ESC':
|
|
541
|
+
result = None; break
|
|
542
|
+
elif key in ('TAB', 'BACKTAB'):
|
|
543
|
+
count = _count(); cbuf.clear()
|
|
544
|
+
field = (field + (-1 if key == 'BACKTAB' else 1)) % 3
|
|
545
|
+
_render()
|
|
546
|
+
# 'q' quits only outside the free-text Rater field (an email may contain 'q').
|
|
547
|
+
elif keys.pressed(key, 'list.quit') and field != 2: # field 2 is the e-mail text
|
|
548
|
+
raise QuitToTerminal() # q quits the app; it never just leaves a widget
|
|
549
|
+
elif field == 0:
|
|
550
|
+
if key in ('LEFT', 'DOWN'):
|
|
551
|
+
stars = max(0, stars - 1); _render()
|
|
552
|
+
elif key in ('RIGHT', 'UP'):
|
|
553
|
+
stars = min(5, stars + 1); _render()
|
|
554
|
+
elif key.isdigit() and 0 <= int(key) <= 5:
|
|
555
|
+
stars = int(key); _render()
|
|
556
|
+
elif field == 1:
|
|
557
|
+
if key in ('UP', 'RIGHT'):
|
|
558
|
+
count = _count() + 1; cbuf.clear(); _render()
|
|
559
|
+
elif key in ('DOWN', 'LEFT'):
|
|
560
|
+
count = max(0, _count() - 1); cbuf.clear(); _render()
|
|
561
|
+
elif key == 'PGUP':
|
|
562
|
+
count = _count() + 10; cbuf.clear(); _render()
|
|
563
|
+
elif key == 'PGDN':
|
|
564
|
+
count = max(0, _count() - 10); cbuf.clear(); _render()
|
|
565
|
+
elif key.isdigit():
|
|
566
|
+
if len("".join(cbuf)) < 12:
|
|
567
|
+
cbuf.append(key); _render()
|
|
568
|
+
elif key == 'BACKSPACE':
|
|
569
|
+
if cbuf:
|
|
570
|
+
cbuf.pop(); _render()
|
|
571
|
+
elif field == 2:
|
|
572
|
+
if key == 'BACKSPACE':
|
|
573
|
+
email = email[:-1]; _render()
|
|
574
|
+
elif len(key) == 1 and key.isprintable():
|
|
575
|
+
email += key; _render()
|
|
576
|
+
finally:
|
|
577
|
+
disable_mouse()
|
|
578
|
+
_restore_term_attrs(fd, old)
|
|
579
|
+
w.clear()
|
|
580
|
+
|
|
581
|
+
return result
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Raw terminal mode and a non-blocking key read, for the loops that poll for keys
|
|
2
|
+
between redraws (the player, the activity centre, the trim conveyor)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_IS_WINDOWS = os.name == 'nt'
|
|
10
|
+
|
|
11
|
+
# Expose module-level names so static type checkers don't consider them
|
|
12
|
+
# possibly unbound when imports are platform-gated.
|
|
13
|
+
msvcrt: Any | None = None
|
|
14
|
+
select: Any | None = None
|
|
15
|
+
termios: Any | None = None
|
|
16
|
+
|
|
17
|
+
if _IS_WINDOWS:
|
|
18
|
+
import msvcrt as _msvcrt
|
|
19
|
+
msvcrt = _msvcrt
|
|
20
|
+
else:
|
|
21
|
+
import select as _select
|
|
22
|
+
import termios as _termios
|
|
23
|
+
select = _select
|
|
24
|
+
termios = _termios
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def raw_mode(file):
|
|
29
|
+
"""Raw terminal mode: no echo, no canonical input."""
|
|
30
|
+
if _IS_WINDOWS:
|
|
31
|
+
yield
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
# At runtime termios is available on non-Windows platforms; assert
|
|
35
|
+
# so static analyzers know the name is defined.
|
|
36
|
+
assert termios is not None
|
|
37
|
+
old_attrs = termios.tcgetattr(file.fileno())
|
|
38
|
+
new_attrs = termios.tcgetattr(file.fileno())
|
|
39
|
+
new_attrs[3] &= ~(termios.ECHO | termios.ICANON)
|
|
40
|
+
try:
|
|
41
|
+
termios.tcsetattr(file.fileno(), termios.TCSADRAIN, new_attrs)
|
|
42
|
+
yield
|
|
43
|
+
finally:
|
|
44
|
+
termios.tcsetattr(file.fileno(), termios.TCSADRAIN, old_attrs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_key_non_blocking() -> str | None:
|
|
48
|
+
"""The next key if one is waiting, else None, without blocking. Decoded by
|
|
49
|
+
prompt.core's reader, the one every screen uses, so keys arrive by the same
|
|
50
|
+
names everywhere ('UP', 'ESC', 'SPACE', 'MOUSE_CLICK:…')."""
|
|
51
|
+
if _IS_WINDOWS:
|
|
52
|
+
assert msvcrt is not None
|
|
53
|
+
if not msvcrt.kbhit():
|
|
54
|
+
return None
|
|
55
|
+
else:
|
|
56
|
+
assert select is not None
|
|
57
|
+
if not select.select([sys.stdin.fileno()], [], [], 0)[0]:
|
|
58
|
+
return None
|
|
59
|
+
from backbone.prompt.core import _read_key_raw
|
|
60
|
+
return _read_key_raw(sys.stdin.fileno()) or None
|
backbone/timefmt.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Timestamps as text: the editors' mm:ss.mmm clock and SRT's HH:MM:SS,mmm.
|
|
2
|
+
Both round to whole milliseconds first, so 59.9996 s reads 01:00.000, never
|
|
3
|
+
00:60.000."""
|
|
4
|
+
|
|
5
|
+
CLOCK_UNTIMED = "──:──.───"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _ms(t: float) -> int:
|
|
9
|
+
return round(max(0.0, float(t)) * 1000)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def clock(t: float | None) -> str:
|
|
13
|
+
"""Seconds as MM:SS.mmm (minutes grow past 99), or a dashed placeholder
|
|
14
|
+
when there is no time."""
|
|
15
|
+
if t is None:
|
|
16
|
+
return CLOCK_UNTIMED
|
|
17
|
+
m, ms = divmod(_ms(t), 60000)
|
|
18
|
+
return f"{m:02d}:{ms // 1000:02d}.{ms % 1000:03d}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def srt(t: float) -> str:
|
|
22
|
+
"""Seconds as an SRT timestamp, HH:MM:SS,mmm."""
|
|
23
|
+
h, ms = divmod(_ms(t), 3600000)
|
|
24
|
+
return f"{h:02d}:{ms // 60000:02d}:{ms // 1000 % 60:02d},{ms % 1000:03d}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
assert clock(59.9996) == "01:00.000"
|
|
29
|
+
assert clock(125.5) == "02:05.500"
|
|
30
|
+
assert clock(None) == CLOCK_UNTIMED
|
|
31
|
+
assert clock(-3) == "00:00.000"
|
|
32
|
+
assert srt(3599.9996) == "01:00:00,000"
|
|
33
|
+
assert srt(3723.042) == "01:02:03,042"
|
|
34
|
+
print("ok")
|