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,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")