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/ui.py
ADDED
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
"""Terminal helpers: ANSI colours, sizing and resize tracking, the display-width
|
|
2
|
+
scanner, formatting, the status bar, the now-playing box registry, the alt
|
|
3
|
+
screen and the progress bar."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import shutil
|
|
8
|
+
import signal
|
|
9
|
+
import time as _time
|
|
10
|
+
from typing import Any
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from backbone.nav import NAV_STACK
|
|
16
|
+
from backbone.log import quietly
|
|
17
|
+
|
|
18
|
+
# Status-bar messages: an ordinary one, and a warning or error worth reading.
|
|
19
|
+
STATUS_S = 3.0
|
|
20
|
+
STATUS_WARNING_S = 5.0
|
|
21
|
+
|
|
22
|
+
_resize_flag = False
|
|
23
|
+
_footer_layout_dirty = False
|
|
24
|
+
|
|
25
|
+
# Memoised terminal size. `get_terminal_size` was an ioctl per call and the render
|
|
26
|
+
# path calls it per *line* (clipping) as well as per frame; the size only changes
|
|
27
|
+
# on SIGWINCH, which clears this. Without SIGWINCH (Windows) it re-reads on a
|
|
28
|
+
# short TTL instead.
|
|
29
|
+
_size_cache: tuple[int, int] | None = None
|
|
30
|
+
_size_cache_at: float = 0.0
|
|
31
|
+
_SIZE_TTL = 0.25
|
|
32
|
+
|
|
33
|
+
_last_resize_signal = 0.0 # monotonic time of the last SIGWINCH
|
|
34
|
+
_cell_aspect_cache: float | None = None # see cell_aspect(); a resize clears it
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _sigwinch_handler(signum: int, frame: Any) -> None:
|
|
38
|
+
"""Mark that the terminal was resized; consume_resize() picks this up."""
|
|
39
|
+
global _resize_flag, _size_cache, _cell_aspect_cache, _last_resize_signal
|
|
40
|
+
_resize_flag = True
|
|
41
|
+
_size_cache = None # the memoised size is now wrong
|
|
42
|
+
_cell_aspect_cache = None # and so may the cell size be (a font change)
|
|
43
|
+
_last_resize_signal = _time.monotonic()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def last_resize_signal_at() -> float:
|
|
47
|
+
"""Monotonic time of the last resize report, to tell whether one arrived
|
|
48
|
+
while something slow was under way."""
|
|
49
|
+
return _last_resize_signal
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ms_since_resize_signal() -> float:
|
|
53
|
+
"""How long ago the terminal last reported a resize, in ms, for the
|
|
54
|
+
diagnostics log, to show how far behind the resize a redraw landed."""
|
|
55
|
+
return (_time.monotonic() - _last_resize_signal) * 1000
|
|
56
|
+
|
|
57
|
+
# SIGWINCH doesn't exist on Windows; guard so importing ui never raises there.
|
|
58
|
+
_HAS_SIGWINCH = hasattr(signal, "SIGWINCH")
|
|
59
|
+
if _HAS_SIGWINCH:
|
|
60
|
+
signal.signal(signal.SIGWINCH, _sigwinch_handler)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def mark_footer_layout_dirty() -> None:
|
|
64
|
+
"""Signal that the now-playing box's height changed (it appeared or vanished,
|
|
65
|
+
e.g. the player view opened in another window), so menus re-render and
|
|
66
|
+
re-reserve rows for it via consume_resize()."""
|
|
67
|
+
global _footer_layout_dirty
|
|
68
|
+
_footer_layout_dirty = True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def consume_resize() -> bool:
|
|
72
|
+
"""True (and clears the flags) if the terminal was resized *or* the now-playing
|
|
73
|
+
box changed height since last call: both need a full re-render/re-layout."""
|
|
74
|
+
global _resize_flag, _footer_layout_dirty
|
|
75
|
+
if _resize_flag or _footer_layout_dirty:
|
|
76
|
+
_resize_flag = False
|
|
77
|
+
_footer_layout_dirty = False
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
# Global content margins. All widgets and the playback UI read from here:
|
|
82
|
+
# change these two values to tune the whole app at once.
|
|
83
|
+
MARGIN_H = 2 # columns reserved on each horizontal side (left and right)
|
|
84
|
+
MARGIN_V = 1 # rows reserved on each vertical side (top and bottom)
|
|
85
|
+
|
|
86
|
+
# Now-playing box transport geometry, shared so both places that depend on it
|
|
87
|
+
# can't drift apart: `now_playing_box.format_now_playing_bar` draws the glyphs here
|
|
88
|
+
# and `prompt.core.footer_click_action` maps a click back to the one under
|
|
89
|
+
# the pointer. (start column, width) of ⏸/⏵ and ⏭ in the box's content columns.
|
|
90
|
+
#
|
|
91
|
+
# No previous-track button: the box is an ambient reminder of what is playing,
|
|
92
|
+
# and stepping backwards from it is a rarer thing to want than the space a third
|
|
93
|
+
# control costs. ^B still works, and is still advertised in the hint bar.
|
|
94
|
+
FOOTER_GLYPH_COLS = ((0, 2), (4, 2))
|
|
95
|
+
|
|
96
|
+
class Colors:
|
|
97
|
+
"""The named palette every widget uses, plus semantic colours for a tool's
|
|
98
|
+
own views (FRAME, TEAL, AMBER, RED, TXT, MUTE) and the short aliases R and B.
|
|
99
|
+
All empty when colour is off (NO_COLOR, or set_colour(False))."""
|
|
100
|
+
PRIMARY = "\033[1;37m" # Bold white
|
|
101
|
+
WHITE = "\033[37m" # Normal white
|
|
102
|
+
ACCENT = "\033[1;32m" # green; a host's setting changes it through set_accent
|
|
103
|
+
CYAN = "\033[1;36m"
|
|
104
|
+
YELLOW = "\033[1;33m"
|
|
105
|
+
MAGENTA = "\033[1;35m"
|
|
106
|
+
GREEN = "\033[1;32m"
|
|
107
|
+
DIM = "\033[2m"
|
|
108
|
+
BOLD = "\033[1m"
|
|
109
|
+
ITALIC = "\033[3m"
|
|
110
|
+
UNDERLINE = "\033[4m"
|
|
111
|
+
RESET = "\033[0m"
|
|
112
|
+
BACK = "\x1b[47m"
|
|
113
|
+
INVERT = "\033[7m"
|
|
114
|
+
HIDE = "\033[?25l"
|
|
115
|
+
SHOW = "\033[?25h"
|
|
116
|
+
# semantic colours for a tool's own views (backcrack's watch uses RED for failures)
|
|
117
|
+
FRAME = "\033[38;5;239m"
|
|
118
|
+
TEAL = "\033[38;5;43m"
|
|
119
|
+
AMBER = "\033[38;5;179m"
|
|
120
|
+
RED = "\033[38;5;167m"
|
|
121
|
+
TXT = "\033[38;5;252m"
|
|
122
|
+
MUTE = "\033[38;5;243m"
|
|
123
|
+
R = RESET # short aliases
|
|
124
|
+
B = BOLD
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# The styling half of Colors: everything that paints rather than moves the
|
|
128
|
+
# cursor. Suppressing colour must not suppress HIDE/SHOW, which are cursor
|
|
129
|
+
# control and still needed on a pipe.
|
|
130
|
+
_STYLE_NAMES = ('PRIMARY', 'WHITE', 'ACCENT', 'CYAN', 'YELLOW', 'MAGENTA', 'GREEN',
|
|
131
|
+
'DIM', 'BOLD', 'ITALIC', 'UNDERLINE', 'RESET', 'BACK', 'INVERT',
|
|
132
|
+
'FRAME', 'TEAL', 'AMBER', 'RED', 'TXT', 'MUTE', 'R', 'B')
|
|
133
|
+
_STYLE_CODES = {name: getattr(Colors, name) for name in _STYLE_NAMES}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def colour_enabled() -> bool:
|
|
137
|
+
"""Whether colour should be emitted: a terminal, and NO_COLOR unset.
|
|
138
|
+
|
|
139
|
+
An empty NO_COLOR still counts as set: that is what the convention says,
|
|
140
|
+
and `NO_COLOR=` in an environment is a deliberate act.
|
|
141
|
+
"""
|
|
142
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
143
|
+
return False
|
|
144
|
+
try:
|
|
145
|
+
return bool(sys.stdout.isatty())
|
|
146
|
+
except (AttributeError, ValueError):
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def set_colour(enabled: bool) -> None:
|
|
151
|
+
"""Turn every style code in `Colors` on or off, for the whole process.
|
|
152
|
+
|
|
153
|
+
One switch rather than a check at each of the several hundred places a style
|
|
154
|
+
is interpolated: the table renderer, the hint engine, the status bar and
|
|
155
|
+
every screen already read their codes from here, so a pipe gets plain text
|
|
156
|
+
without any of them knowing about it.
|
|
157
|
+
"""
|
|
158
|
+
global _colour_on
|
|
159
|
+
_colour_on = enabled
|
|
160
|
+
for name, code in _STYLE_CODES.items():
|
|
161
|
+
setattr(Colors, name, code if enabled else "")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_colour_on = True
|
|
165
|
+
|
|
166
|
+
# NO_COLOR turns colour off from the start; a host can also call set_colour.
|
|
167
|
+
USE_COLOR = os.environ.get("NO_COLOR") is None
|
|
168
|
+
if not USE_COLOR:
|
|
169
|
+
set_colour(False)
|
|
170
|
+
|
|
171
|
+
# The accent: terminal-palette colours first (they follow the terminal's own
|
|
172
|
+
# theme), then fixed ones. Stored in `accent_colour` as a key or as "#RRGGBB".
|
|
173
|
+
ACCENT_PRESETS = [
|
|
174
|
+
('green', 'Green', 32), ('red', 'Red', 31), ('yellow', 'Yellow', 33),
|
|
175
|
+
('blue', 'Blue', 34), ('magenta', 'Magenta', 35), ('cyan', 'Cyan', 36),
|
|
176
|
+
('amber', 'Amber', '#FFB000'), ('coral', 'Coral', '#FF7F66'), ('rose', 'Rose', '#F06292'),
|
|
177
|
+
('lavender', 'Lavender', '#B39DDB'), ('sky', 'Sky', '#4FC3F7'), ('mint', 'Mint', '#6FDFA8'),
|
|
178
|
+
]
|
|
179
|
+
DEFAULT_ACCENT = 'green'
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parse_hex_colour(text: str) -> tuple[int, int, int] | None:
|
|
183
|
+
"""(r, g, b) from "#RRGGBB", "RRGGBB" or "#RGB", or None."""
|
|
184
|
+
h = (text or '').strip().lstrip('#')
|
|
185
|
+
if len(h) == 3:
|
|
186
|
+
h = ''.join(c * 2 for c in h)
|
|
187
|
+
if len(h) != 6:
|
|
188
|
+
return None
|
|
189
|
+
try:
|
|
190
|
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
191
|
+
except ValueError:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _rgb_code(r: int, g: int, b: int) -> str:
|
|
196
|
+
"""Bold foreground in exactly this colour on a 24-bit terminal, else the
|
|
197
|
+
nearest of the 256-colour palette's 6×6×6 cube or grey ramp."""
|
|
198
|
+
if os.environ.get('COLORTERM', '').lower() in ('truecolor', '24bit'):
|
|
199
|
+
return f"\033[1;38;2;{r};{g};{b}m"
|
|
200
|
+
if max(r, g, b) - min(r, g, b) < 12: # a grey
|
|
201
|
+
n = 232 + min(23, max(0, round((r - 8) / 10)))
|
|
202
|
+
else:
|
|
203
|
+
n = 16 + sum(round(v / 255 * 5) * m for v, m in ((r, 36), (g, 6), (b, 1)))
|
|
204
|
+
return f"\033[1;38;5;{n}m"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def accent_code(value) -> str | None:
|
|
208
|
+
"""The escape code for an `accent_colour` value (a preset key or a hex
|
|
209
|
+
colour), or None when it is neither."""
|
|
210
|
+
for key, _name, colour in ACCENT_PRESETS:
|
|
211
|
+
if value == key:
|
|
212
|
+
return f"\033[1;{colour}m" if isinstance(colour, int) else _rgb_code(*parse_hex_colour(colour))
|
|
213
|
+
rgb = parse_hex_colour(value) if isinstance(value, str) and value.startswith('#') else None
|
|
214
|
+
return _rgb_code(*rgb) if rgb else None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def accent_label(value) -> str:
|
|
218
|
+
"""What Settings calls an `accent_colour` value."""
|
|
219
|
+
return next((name for key, name, _ in ACCENT_PRESETS if key == value),
|
|
220
|
+
str(value).upper() if accent_code(value) else 'Green')
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def set_accent(value) -> None:
|
|
224
|
+
"""Use `value` (an `accent_colour` setting) as the accent from now on; an
|
|
225
|
+
unknown value falls back to the default."""
|
|
226
|
+
code = accent_code(value) or accent_code(DEFAULT_ACCENT)
|
|
227
|
+
_STYLE_CODES['ACCENT'] = code
|
|
228
|
+
if _colour_on:
|
|
229
|
+
Colors.ACCENT = code
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
_screen_invalidator = None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def set_screen_invalidator(fn) -> None:
|
|
236
|
+
"""Register the painter's "forget what's on screen" hook.
|
|
237
|
+
|
|
238
|
+
Registered by `prompt.core` (which can't be imported here, as it imports this
|
|
239
|
+
module), so every existing `clear_screen()` keeps meaning "the screen is now
|
|
240
|
+
blank" for the diffed painter as well.
|
|
241
|
+
"""
|
|
242
|
+
global _screen_invalidator
|
|
243
|
+
_screen_invalidator = fn
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _screen_cleared() -> None:
|
|
247
|
+
"""Tell the painter the screen was wiped outside its own frame writes."""
|
|
248
|
+
if _screen_invalidator is not None:
|
|
249
|
+
with quietly():
|
|
250
|
+
_screen_invalidator()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def enter_alt_screen() -> None:
|
|
254
|
+
"""Switch to the terminal alternate screen buffer (no scrollback).
|
|
255
|
+
|
|
256
|
+
Also enables focus in/out reporting (\\033[?1004h) so editors can show a
|
|
257
|
+
hollow cursor when the window loses focus; unsupported terminals ignore it.
|
|
258
|
+
And turns auto-wrap off (\\033[?7l): every row is placed explicitly, so a
|
|
259
|
+
row too wide for the window should be cut at the edge, not run onto the
|
|
260
|
+
next, which is what a write still sized for the old width does in the
|
|
261
|
+
moment between a resize and the redraw that answers it.
|
|
262
|
+
"""
|
|
263
|
+
sys.stdout.write("\033[?1049h\033[?1004h\033[?7l\033[H\033[3J\033[J" + Colors.HIDE)
|
|
264
|
+
sys.stdout.flush()
|
|
265
|
+
_screen_cleared()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def exit_alt_screen() -> None:
|
|
269
|
+
"""Restore the main screen buffer, auto-wrap and focus reporting, and show
|
|
270
|
+
the cursor."""
|
|
271
|
+
sys.stdout.write("\033[?25h\033[?7h\033[?1004l\033[?1049l")
|
|
272
|
+
sys.stdout.flush()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def clear_screen() -> None:
|
|
276
|
+
"""Overwrite screen content from home without triggering scrollback save.
|
|
277
|
+
|
|
278
|
+
Leaves the cursor **hidden**: a bare clear parks it at home, where it blinks
|
|
279
|
+
in the top-left corner until the next frame happens to hide it; during a
|
|
280
|
+
library build or any slow step, that's a visible flashing caret.
|
|
281
|
+
"""
|
|
282
|
+
sys.stdout.write("\033[H\033[3J\033[J" + Colors.HIDE)
|
|
283
|
+
sys.stdout.flush()
|
|
284
|
+
_screen_cleared()
|
|
285
|
+
|
|
286
|
+
BACKGROUND_TASKS: dict[str, str] = {}
|
|
287
|
+
|
|
288
|
+
_toast_message: str = ""
|
|
289
|
+
_toast_expiry: float = 0.0
|
|
290
|
+
|
|
291
|
+
# Now-playing box: the playback layer registers a provider so the widget
|
|
292
|
+
# layer can draw a background-audio box without importing playback (keeps the
|
|
293
|
+
# dependency flowing one way). provider(width) -> list[str] | None (styled rows,
|
|
294
|
+
# top to bottom; drawn just above the breadcrumb status line).
|
|
295
|
+
_footer_provider = None
|
|
296
|
+
_footer_lines_cache: list[str] = []
|
|
297
|
+
# A cheap identity of the currently-shown track (file/generation/paused/index …).
|
|
298
|
+
# The idle-tick redraw keys off this so a background track change always repaints
|
|
299
|
+
# the box, even in the rare case two tracks render to byte-identical rows.
|
|
300
|
+
_footer_sig: tuple | None = None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def set_footer_provider(fn) -> None:
|
|
304
|
+
"""Register a ``callable(width) -> list[str] | None`` that renders the now-playing box."""
|
|
305
|
+
global _footer_provider
|
|
306
|
+
_footer_provider = fn
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def set_footer_signature(sig: tuple | None) -> None:
|
|
310
|
+
"""Record the identity of the track the box provider just rendered."""
|
|
311
|
+
global _footer_sig
|
|
312
|
+
_footer_sig = sig
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# Event-driven repaint: background threads (a joined window's snapshot
|
|
316
|
+
# receiver, the host's auto-advance tick) call pulse_footer() when the
|
|
317
|
+
# now-playing state changes so the menu poll repaints the box *immediately*
|
|
318
|
+
# instead of only on the next keystroke. The waker is registered by the input
|
|
319
|
+
# layer (a self-pipe that wakes its select); this hook keeps the playback/IPC
|
|
320
|
+
# threads free of any input-layer import.
|
|
321
|
+
_footer_waker = None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def set_footer_waker(fn) -> None:
|
|
325
|
+
"""Register a ``callable()`` that nudges the menu poll to repaint the box."""
|
|
326
|
+
global _footer_waker
|
|
327
|
+
_footer_waker = fn
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def pulse_footer() -> None:
|
|
331
|
+
"""Ask the active menu poll to repaint the now-playing box now (no-op if no
|
|
332
|
+
poll is listening, e.g. the full player view drives its own redraws)."""
|
|
333
|
+
if _footer_waker is not None:
|
|
334
|
+
with quietly():
|
|
335
|
+
_footer_waker()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def footer_signature() -> tuple | None:
|
|
339
|
+
"""The identity of the currently-shown track (see :func:`set_footer_signature`)."""
|
|
340
|
+
return _footer_sig
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def footer_lines(width: int) -> list[str]:
|
|
344
|
+
"""The now-playing box rows for ``width`` (empty list when nothing is playing)."""
|
|
345
|
+
global _footer_lines_cache
|
|
346
|
+
if _footer_provider is None:
|
|
347
|
+
_footer_lines_cache = []
|
|
348
|
+
return []
|
|
349
|
+
try:
|
|
350
|
+
lines = _footer_provider(width) or []
|
|
351
|
+
except Exception:
|
|
352
|
+
# A provider that raised tells us nothing about what's playing; keep the
|
|
353
|
+
# box exactly as it was rather than blinking it out and back next tick.
|
|
354
|
+
return _footer_lines_cache
|
|
355
|
+
_footer_lines_cache = list(lines)
|
|
356
|
+
return _footer_lines_cache
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def footer_active() -> bool:
|
|
360
|
+
"""Whether a now-playing box is currently shown (cached from the last draw)."""
|
|
361
|
+
return bool(_footer_lines_cache)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# Audio is playing but the box could not be drawn: the terminal is too narrow
|
|
365
|
+
# for it. The box normally advertises the transport keys in its own top border,
|
|
366
|
+
# so this is the one state where the hint bar has to advertise them instead
|
|
367
|
+
# (see `prompt.chrome_hint_pairs`). Deliberately *not* set when the full player
|
|
368
|
+
# view owns the display: that view shows its own transport.
|
|
369
|
+
_footer_unboxed: bool = False
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def set_footer_unboxed(value: bool) -> None:
|
|
373
|
+
"""Record whether transport is live with no now-playing box to advertise it."""
|
|
374
|
+
global _footer_unboxed
|
|
375
|
+
_footer_unboxed = bool(value)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def footer_unboxed() -> bool:
|
|
379
|
+
"""Whether transport is live but no box is drawn to show its keys."""
|
|
380
|
+
return _footer_unboxed
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def footer_height() -> int:
|
|
384
|
+
"""How many rows the now-playing box currently occupies (0 when inactive)."""
|
|
385
|
+
return len(_footer_lines_cache)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def set_status(task_id: str, message: str | None) -> None:
|
|
389
|
+
"""Update or remove a background task status."""
|
|
390
|
+
if message is None:
|
|
391
|
+
BACKGROUND_TASKS.pop(task_id, None)
|
|
392
|
+
else:
|
|
393
|
+
BACKGROUND_TASKS[task_id] = message
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def has_background_tasks() -> bool:
|
|
397
|
+
"""Whether any background activity is currently running (drives the live,
|
|
398
|
+
pulsing status indicator so the notice stays up until the work is done)."""
|
|
399
|
+
return bool(BACKGROUND_TASKS)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# 256-colour greyscale brightness ramp (dim → white → dim) for the pulsing beacon.
|
|
403
|
+
_PULSE_RAMP = (238, 243, 248, 253, 255, 253, 248, 243)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def pulse_circle() -> str:
|
|
407
|
+
"""A white ● whose brightness pulses over time: the beacon next to a running
|
|
408
|
+
background activity. The status bar is re-rendered ~8 Hz while a task is
|
|
409
|
+
active (see the menu idle tick), which animates this."""
|
|
410
|
+
code = _PULSE_RAMP[int(_time.time() * 6) % len(_PULSE_RAMP)]
|
|
411
|
+
return f"\033[38;5;{code}m●{Colors.RESET}"
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def print_inline_progress(message: str, progress: float) -> None:
|
|
415
|
+
"""Redraw a single in-place line for a blocking, no-other-redraw loop
|
|
416
|
+
(a per-track ffmpeg pass): pulsing beacon + bar so a slow scan still
|
|
417
|
+
looks alive instead of a hung terminal, inset by MARGIN_H and centred
|
|
418
|
+
like the rest of the chrome. Call `clear_inline_progress()` once the
|
|
419
|
+
loop finishes."""
|
|
420
|
+
bar = get_progress_bar(progress, 24)
|
|
421
|
+
width = get_terminal_width()
|
|
422
|
+
avail = max(1, width - 2 * MARGIN_H)
|
|
423
|
+
prefix_len = visual_len(f"{pulse_circle()} {bar} ")
|
|
424
|
+
if prefix_len + len(message) > avail:
|
|
425
|
+
message = message[:max(0, avail - prefix_len - 1)] + "…"
|
|
426
|
+
content = f"{pulse_circle()} {bar} {Colors.DIM}{message}{Colors.RESET}"
|
|
427
|
+
pad = max(MARGIN_H, (width - visual_len(content)) // 2)
|
|
428
|
+
sys.stdout.write(f"\r{' ' * pad}{content}\033[K")
|
|
429
|
+
sys.stdout.flush()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def clear_inline_progress() -> None:
|
|
433
|
+
"""Erase the line left by `print_inline_progress()`."""
|
|
434
|
+
sys.stdout.write("\r\033[K")
|
|
435
|
+
sys.stdout.flush()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def show_status(message: str, duration: float = STATUS_S) -> None:
|
|
439
|
+
"""Flash a one-shot message in the status bar for `duration` seconds."""
|
|
440
|
+
global _toast_message, _toast_expiry
|
|
441
|
+
_toast_message = message
|
|
442
|
+
_toast_expiry = _time.time() + duration
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def show_loading(message: str) -> None:
|
|
446
|
+
"""Clear the screen and display a greyed loading message during long operations."""
|
|
447
|
+
clear_screen()
|
|
448
|
+
sys.stdout.write(f"\n {Colors.DIM}{message}{Colors.RESET}\n")
|
|
449
|
+
sys.stdout.flush()
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def get_status_line() -> str:
|
|
453
|
+
"""Return the current status bar content (breadcrumb + tasks + toast)."""
|
|
454
|
+
global _toast_message
|
|
455
|
+
cols = get_terminal_width()
|
|
456
|
+
|
|
457
|
+
if cols <= 0:
|
|
458
|
+
return ""
|
|
459
|
+
|
|
460
|
+
if _toast_message and _time.time() > _toast_expiry:
|
|
461
|
+
_toast_message = ""
|
|
462
|
+
|
|
463
|
+
sep = f" {Colors.DIM}·{Colors.RESET} "
|
|
464
|
+
|
|
465
|
+
right_parts: list[str] = []
|
|
466
|
+
for msg in BACKGROUND_TASKS.values():
|
|
467
|
+
right_parts.append(f"{pulse_circle()} {Colors.DIM}{msg}{Colors.RESET}")
|
|
468
|
+
if _toast_message:
|
|
469
|
+
right_parts.append(f"{Colors.DIM}{_toast_message}{Colors.RESET}")
|
|
470
|
+
right = sep.join(right_parts)
|
|
471
|
+
|
|
472
|
+
crumb = _get_breadcrumb_str(cols // 2) if NAV_STACK else ""
|
|
473
|
+
left = f" {Colors.DIM}{crumb}{Colors.RESET}" if crumb else ""
|
|
474
|
+
|
|
475
|
+
if left and right:
|
|
476
|
+
gap = max(2, cols - visual_len(left) - visual_len(right) - 2)
|
|
477
|
+
status = left + " " * gap + right + " "
|
|
478
|
+
elif left:
|
|
479
|
+
status = left
|
|
480
|
+
elif right:
|
|
481
|
+
status = " " + right + " "
|
|
482
|
+
else:
|
|
483
|
+
status = ""
|
|
484
|
+
|
|
485
|
+
if visual_len(status) > cols:
|
|
486
|
+
status = clip_ansi(status, cols)
|
|
487
|
+
return status
|
|
488
|
+
|
|
489
|
+
def _tty_size() -> os.terminal_size:
|
|
490
|
+
"""Ask the terminal itself. shutil.get_terminal_size() prefers exported
|
|
491
|
+
COLUMNS/LINES, which some shells and terminals export once at startup,
|
|
492
|
+
and that froze the size for good. Those only count when no stream is a tty."""
|
|
493
|
+
for stream in (sys.__stdout__, sys.__stdin__, sys.__stderr__):
|
|
494
|
+
try:
|
|
495
|
+
return os.get_terminal_size(stream.fileno())
|
|
496
|
+
except (AttributeError, ValueError, OSError):
|
|
497
|
+
continue
|
|
498
|
+
return shutil.get_terminal_size()
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def get_terminal_size(default: tuple = (80, 24)) -> tuple:
|
|
502
|
+
"""Terminal (columns, rows), falling back to `default` if the query fails.
|
|
503
|
+
|
|
504
|
+
Memoised; see `_size_cache`; a resize (SIGWINCH) clears it.
|
|
505
|
+
"""
|
|
506
|
+
global _size_cache, _size_cache_at
|
|
507
|
+
if _size_cache is not None:
|
|
508
|
+
if _HAS_SIGWINCH or (_time.monotonic() - _size_cache_at) < _SIZE_TTL:
|
|
509
|
+
return _size_cache
|
|
510
|
+
try:
|
|
511
|
+
size = _tty_size()
|
|
512
|
+
except OSError:
|
|
513
|
+
return default
|
|
514
|
+
_size_cache = (size.columns, size.lines)
|
|
515
|
+
_size_cache_at = _time.monotonic()
|
|
516
|
+
return _size_cache
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def cell_aspect(default: float = 2.0) -> float:
|
|
521
|
+
"""How many times taller than wide one character cell is on screen, from
|
|
522
|
+
the pixel size the terminal reports. Anything drawn in cells (half-block
|
|
523
|
+
art, an inline image's box) needs it to keep an image's proportions;
|
|
524
|
+
`default` when the terminal doesn't report pixels. Re-read after a resize,
|
|
525
|
+
which is also what a font change sends."""
|
|
526
|
+
global _cell_aspect_cache
|
|
527
|
+
if _cell_aspect_cache is None:
|
|
528
|
+
_cell_aspect_cache = default
|
|
529
|
+
try:
|
|
530
|
+
import fcntl, struct, termios
|
|
531
|
+
for stream in (sys.__stdout__, sys.__stdin__, sys.__stderr__):
|
|
532
|
+
try:
|
|
533
|
+
r, c, xpx, ypx = struct.unpack(
|
|
534
|
+
"HHHH", fcntl.ioctl(stream.fileno(), termios.TIOCGWINSZ, b"\0" * 8))
|
|
535
|
+
except (AttributeError, ValueError, OSError):
|
|
536
|
+
continue
|
|
537
|
+
if r and c and xpx and ypx:
|
|
538
|
+
_cell_aspect_cache = (ypx / r) / (xpx / c)
|
|
539
|
+
break
|
|
540
|
+
except ImportError: # Windows: no ioctl
|
|
541
|
+
pass
|
|
542
|
+
return _cell_aspect_cache
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def get_terminal_width(default: int = 80) -> int:
|
|
546
|
+
"""Terminal width in columns."""
|
|
547
|
+
cols, _ = get_terminal_size((default, default))
|
|
548
|
+
return cols
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def get_terminal_height(default: int = 24) -> int:
|
|
552
|
+
"""Terminal height in rows."""
|
|
553
|
+
_, rows = get_terminal_size((default, default))
|
|
554
|
+
return rows
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
# ---------------------------------------------------------------------------
|
|
558
|
+
# Display width: one ANSI scanner, one column table, for the whole app.
|
|
559
|
+
#
|
|
560
|
+
# Every module that measures, clips or pads a styled line goes through this
|
|
561
|
+
# block.
|
|
562
|
+
# ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
# The escape sequences a terminal consumes without drawing anything. In order:
|
|
565
|
+
# CSI (including private `?` parameters and intermediate bytes, so `\033[?25l`
|
|
566
|
+
# and `\033[3J` are recognised, not just SGR); the Kitty graphics protocol used
|
|
567
|
+
# by the album-art renderer; OSC, terminated by either ST or BEL; the remaining
|
|
568
|
+
# string-introducers (DCS/SOS/PM/APC); and a bare two-byte escape as a backstop.
|
|
569
|
+
_ANSI_RE = re.compile(
|
|
570
|
+
r'(\x1b\[[0-9;?]*[ -/]*[@-~])'
|
|
571
|
+
r'|(\x1b_G[^\x1b]*\x1b\\)'
|
|
572
|
+
r'|(\x1b\][^\x1b\x07]*(?:\x1b\\|\x07))'
|
|
573
|
+
r'|(\x1b[PX^_].*?\x1b\\)'
|
|
574
|
+
r'|(\x1b.)'
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _scan(s: str):
|
|
579
|
+
"""Yield ``(is_escape, chunk)`` across `s`.
|
|
580
|
+
|
|
581
|
+
One chunk per escape sequence, one per printable character: the single
|
|
582
|
+
walk every width routine below is built on, so measuring and clipping can
|
|
583
|
+
never disagree about where an escape starts or ends.
|
|
584
|
+
"""
|
|
585
|
+
i = 0
|
|
586
|
+
n = len(s)
|
|
587
|
+
while i < n:
|
|
588
|
+
if s[i] == '\x1b':
|
|
589
|
+
m = _ANSI_RE.match(s, i)
|
|
590
|
+
if m:
|
|
591
|
+
yield True, m.group(0)
|
|
592
|
+
i = m.end()
|
|
593
|
+
continue
|
|
594
|
+
yield False, s[i]
|
|
595
|
+
i += 1
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def strip_ansi(s: str) -> str:
|
|
599
|
+
"""`s` with every ANSI escape sequence removed."""
|
|
600
|
+
if '\x1b' not in s:
|
|
601
|
+
return s
|
|
602
|
+
return _ANSI_RE.sub('', s)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# Codepoints that occupy no column of their own: the variation selectors that
|
|
606
|
+
# pick a glyph's text/emoji presentation, the zero-width joiner family, and
|
|
607
|
+
# combining marks that stack onto the character before them.
|
|
608
|
+
_ZERO_WIDTH = ('︎', '️', '', '', '')
|
|
609
|
+
|
|
610
|
+
_ZERO_WIDTH_SET = frozenset(_ZERO_WIDTH)
|
|
611
|
+
|
|
612
|
+
# Codepoints a terminal draws two cells wide. Two sources: East Asian Wide and
|
|
613
|
+
# Fullwidth (handled below via unicodedata), and emoji-presentation characters,
|
|
614
|
+
# which UTR#51 says to render wide and which every modern terminal does. The
|
|
615
|
+
# media-control glyphs are the app's own case, and their true width is not
|
|
616
|
+
# knowable from Unicode alone. U+23EE ⏮ and U+23ED ⏭ are emoji-by-default; U+23F8
|
|
617
|
+
# ⏸ and U+23F5 ⏵ are text-by-default and "should" be one cell, but no monospace
|
|
618
|
+
# font on a stock macOS box carries any of them, so they are drawn by whichever
|
|
619
|
+
# fallback font does, and Apple Color Emoji (which has ⏮ ⏸ ⏭) draws two cells
|
|
620
|
+
# wide whatever the default presentation says. All four report east-asian-width
|
|
621
|
+
# N, so nothing available here can tell them apart.
|
|
622
|
+
#
|
|
623
|
+
# They are listed as wide deliberately, as an over-estimate. Reserving two cells
|
|
624
|
+
# and getting one leaves a small gap; reserving one and getting two overruns
|
|
625
|
+
# whatever sits to the right, and that is the now-playing box's closing border.
|
|
626
|
+
_WIDE_SET = frozenset('⏮⏭⏸⏵⏪⏩⏫⏬⏯⏱⏲⏰')
|
|
627
|
+
|
|
628
|
+
_cols_cache: dict = {}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def char_cols(ch: str) -> int:
|
|
632
|
+
"""How many terminal columns `ch` occupies: 0, 1 or 2."""
|
|
633
|
+
w = _cols_cache.get(ch)
|
|
634
|
+
if w is None:
|
|
635
|
+
if ch in _ZERO_WIDTH_SET or unicodedata.combining(ch):
|
|
636
|
+
w = 0
|
|
637
|
+
elif ch in _WIDE_SET or unicodedata.east_asian_width(ch) in ('W', 'F'):
|
|
638
|
+
w = 2
|
|
639
|
+
else:
|
|
640
|
+
w = 1
|
|
641
|
+
_cols_cache[ch] = w
|
|
642
|
+
return w
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def display_text(s: str) -> str:
|
|
646
|
+
"""`s` with ANSI escapes and zero-width codepoints removed.
|
|
647
|
+
|
|
648
|
+
Every remaining character occupies at least one column, but *not* always
|
|
649
|
+
exactly one: a wide glyph still takes two, so `visual_len` is what you
|
|
650
|
+
want for width maths. This is for callers that need the plain characters
|
|
651
|
+
themselves (cursor hit-testing, writing a styled report out as text).
|
|
652
|
+
"""
|
|
653
|
+
out = strip_ansi(s)
|
|
654
|
+
if out.isascii():
|
|
655
|
+
return out
|
|
656
|
+
return ''.join(ch for ch in out if char_cols(ch) != 0)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def visual_len(s: str) -> int:
|
|
660
|
+
"""Columns `s` occupies on screen, ignoring ANSI escapes.
|
|
661
|
+
|
|
662
|
+
Not the same as `len`: escapes and combining marks take no column, and an
|
|
663
|
+
emoji-presentation or East-Asian-wide character takes two. The ASCII fast
|
|
664
|
+
path keeps the common case a plain length, as this is called per line in the
|
|
665
|
+
render path.
|
|
666
|
+
"""
|
|
667
|
+
if not s:
|
|
668
|
+
return 0
|
|
669
|
+
if s.isascii() and '\x1b' not in s:
|
|
670
|
+
return len(s)
|
|
671
|
+
return sum(char_cols(ch) for esc, ch in _scan(s) if not esc)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def clip_ansi(text: str, max_cols: int, reset: bool = True) -> str:
|
|
675
|
+
"""`text` truncated to `max_cols` visible columns, escapes preserved.
|
|
676
|
+
|
|
677
|
+
Escapes ride along without being counted, so the styling that survives the
|
|
678
|
+
cut still closes properly; a zero-width mark rides along with the glyph it
|
|
679
|
+
belongs to; and a two-cell glyph is never split across the boundary: it is
|
|
680
|
+
dropped whole, leaving a one-column gap, because half of one renders as a
|
|
681
|
+
stray cell that pushes everything after it out of line.
|
|
682
|
+
|
|
683
|
+
`reset` appends a reset when the string was actually cut, so the clipped
|
|
684
|
+
line can't bleed colour into whatever is drawn after it. Callers that go on
|
|
685
|
+
to concatenate more styled content onto the result pass False.
|
|
686
|
+
"""
|
|
687
|
+
if max_cols <= 0:
|
|
688
|
+
return ""
|
|
689
|
+
out: list[str] = []
|
|
690
|
+
visible = 0
|
|
691
|
+
truncated = False
|
|
692
|
+
for esc, chunk in _scan(text):
|
|
693
|
+
if esc:
|
|
694
|
+
out.append(chunk)
|
|
695
|
+
continue
|
|
696
|
+
w = char_cols(chunk)
|
|
697
|
+
if w and visible + w > max_cols:
|
|
698
|
+
truncated = True
|
|
699
|
+
break
|
|
700
|
+
out.append(chunk)
|
|
701
|
+
visible += w
|
|
702
|
+
res = "".join(out)
|
|
703
|
+
if truncated and reset and not res.endswith(Colors.RESET):
|
|
704
|
+
res += Colors.RESET
|
|
705
|
+
return res
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def truncate_text(text: str, max_width: int, placeholder: str = "…", front: bool = False) -> str:
|
|
709
|
+
"""Truncate `text` to `max_width` columns, replacing the cut end (or start,
|
|
710
|
+
if `front`) with `placeholder`.
|
|
711
|
+
|
|
712
|
+
Measured in columns rather than codepoints, so a CJK or emoji run is cut
|
|
713
|
+
where it actually reaches the edge instead of a character count that
|
|
714
|
+
overruns it by up to 2×.
|
|
715
|
+
"""
|
|
716
|
+
if text is None:
|
|
717
|
+
return ""
|
|
718
|
+
if visual_len(text) <= max_width:
|
|
719
|
+
return text
|
|
720
|
+
ph_w = visual_len(placeholder)
|
|
721
|
+
if max_width <= ph_w:
|
|
722
|
+
return clip_ansi(text, max_width, reset=False)
|
|
723
|
+
|
|
724
|
+
keep = max_width - ph_w
|
|
725
|
+
if front:
|
|
726
|
+
# Walk from the right, taking whole glyphs until `keep` columns are full.
|
|
727
|
+
taken: list[str] = []
|
|
728
|
+
used = 0
|
|
729
|
+
for ch in reversed(text):
|
|
730
|
+
w = char_cols(ch)
|
|
731
|
+
if w and used + w > keep:
|
|
732
|
+
break
|
|
733
|
+
taken.append(ch)
|
|
734
|
+
used += w
|
|
735
|
+
return placeholder + "".join(reversed(taken))
|
|
736
|
+
return clip_ansi(text, keep, reset=False) + placeholder
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def plural(n: int, singular: str, many: str | None = None) -> str:
|
|
740
|
+
"""``"1 result"`` / ``"156 results"``: the count and its noun, agreeing."""
|
|
741
|
+
return f"{n} {singular if abs(n) == 1 else (many or singular + 's')}"
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def divider(width: int | None = None, char: str = "─") -> str:
|
|
745
|
+
"""A horizontal rule of `char` spanning `width` (or the terminal width)."""
|
|
746
|
+
width = width or get_terminal_width()
|
|
747
|
+
return char * width
|
|
748
|
+
def format_time(seconds: int | float) -> str:
|
|
749
|
+
"""Convert seconds (may be float) to a compact time string.
|
|
750
|
+
|
|
751
|
+
Preserves sub-second precision by appending centiseconds when the
|
|
752
|
+
input contains a fractional portion.
|
|
753
|
+
"""
|
|
754
|
+
try:
|
|
755
|
+
total = float(seconds)
|
|
756
|
+
except (TypeError, ValueError):
|
|
757
|
+
total = 0.0
|
|
758
|
+
|
|
759
|
+
int_sec = int(total)
|
|
760
|
+
frac_cs = int(round((total - int_sec) * 100)) # centiseconds (0-99)
|
|
761
|
+
|
|
762
|
+
intervals = [31536000, 2592000, 86400, 3600, 60, 1]
|
|
763
|
+
parts = []
|
|
764
|
+
rem = int_sec
|
|
765
|
+
for unit in intervals:
|
|
766
|
+
parts.append(rem // unit)
|
|
767
|
+
rem %= unit
|
|
768
|
+
|
|
769
|
+
start = max(0, next((i for i, p in enumerate(parts[:-2]) if p > 0), len(parts) - 2))
|
|
770
|
+
start = min(start, len(parts) - 2)
|
|
771
|
+
|
|
772
|
+
result = [str(parts[start])]
|
|
773
|
+
for p in parts[start + 1:]:
|
|
774
|
+
result.append(str(p).zfill(2))
|
|
775
|
+
|
|
776
|
+
base = ":".join(result)
|
|
777
|
+
if frac_cs:
|
|
778
|
+
return f"{base}.{frac_cs:02d}"
|
|
779
|
+
return base
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _get_breadcrumb_str(width: int) -> str:
|
|
783
|
+
"""Render NAV_STACK as a '>'-joined breadcrumb that fits `width`.
|
|
784
|
+
|
|
785
|
+
Over-long trails shed whole path components from the front, keeping the
|
|
786
|
+
deepest ones: those say where you are; the ones above are context you can
|
|
787
|
+
infer. Slicing the joined string by character instead turned "Bleak
|
|
788
|
+
Expectations > A Childhood Cruelly Kippered" into "…pectations > A Childhood
|
|
789
|
+
Cruelly Kippered", where the leading fragment is a word that was never in
|
|
790
|
+
the path and reads as one.
|
|
791
|
+
|
|
792
|
+
The last component is kept whatever it costs: a breadcrumb that has dropped
|
|
793
|
+
everything still has to name where you are. If it alone doesn't fit, it is
|
|
794
|
+
truncated at its own end so it starts with something real.
|
|
795
|
+
"""
|
|
796
|
+
if width <= 1 or not NAV_STACK:
|
|
797
|
+
return ""
|
|
798
|
+
|
|
799
|
+
sep = " > "
|
|
800
|
+
max_length = max(0, width - 1)
|
|
801
|
+
|
|
802
|
+
if len(sep.join(NAV_STACK)) <= max_length:
|
|
803
|
+
return sep.join(NAV_STACK)
|
|
804
|
+
|
|
805
|
+
# Keep the deepest components that fit, prefixed with "… > " to show the
|
|
806
|
+
# trail was cut. Walk outward from the last one.
|
|
807
|
+
kept: list[str] = [NAV_STACK[-1]]
|
|
808
|
+
for name in reversed(NAV_STACK[:-1]):
|
|
809
|
+
if len("… > " + sep.join([name] + kept)) > max_length:
|
|
810
|
+
break
|
|
811
|
+
kept.insert(0, name)
|
|
812
|
+
|
|
813
|
+
out = "… > " + sep.join(kept)
|
|
814
|
+
if len(out) <= max_length:
|
|
815
|
+
return out
|
|
816
|
+
# Not even the deepest component fits beside the marker: truncate it from
|
|
817
|
+
# its end, so what remains is the start of a real name rather than the tail
|
|
818
|
+
# of one. truncate_text handles a width too small for the ellipsis itself.
|
|
819
|
+
return truncate_text(NAV_STACK[-1], max_length)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def get_progress_bar(progress: float, width: int = 40) -> str:
|
|
824
|
+
"""
|
|
825
|
+
A pip-style progress bar.
|
|
826
|
+
[━━━━━━━━━━━━━━━━━━━━━━━━╸ ]
|
|
827
|
+
"""
|
|
828
|
+
progress = max(0, min(1, progress))
|
|
829
|
+
|
|
830
|
+
filled_width = progress * width
|
|
831
|
+
whole_blocks = int(filled_width)
|
|
832
|
+
remainder = filled_width - whole_blocks
|
|
833
|
+
|
|
834
|
+
bar = "━" * whole_blocks
|
|
835
|
+
|
|
836
|
+
# half-cell tip
|
|
837
|
+
if whole_blocks < width:
|
|
838
|
+
if remainder > 0.6:
|
|
839
|
+
bar += "━" # Almost full
|
|
840
|
+
elif remainder > 0.2:
|
|
841
|
+
bar += "╸" # Partial tip
|
|
842
|
+
else:
|
|
843
|
+
bar += " " # Not enough for a tip yet
|
|
844
|
+
|
|
845
|
+
padding = " " * (width - len(bar))
|
|
846
|
+
|
|
847
|
+
return f"{Colors.DIM}[{Colors.RESET}{Colors.PRIMARY}{bar}{padding}{Colors.RESET}{Colors.DIM}]{Colors.RESET}"
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
C = Colors # the short name every widget uses
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
# --- meters, boxes and live-view pieces (dashboards, progress, sizes) ---
|
|
854
|
+
|
|
855
|
+
SPIN = list("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
|
|
856
|
+
|
|
857
|
+
PARTS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"] # eighth-block fill steps
|
|
858
|
+
|
|
859
|
+
SPARK = "▁▂▃▄▅▆▇█"
|
|
860
|
+
|
|
861
|
+
def content_width(min_width: int = 1) -> int:
|
|
862
|
+
"""Terminal columns available for content, after the global left+right
|
|
863
|
+
margin - the width every box/bar in a frame should be drawn against.
|
|
864
|
+
No floor beyond `min_width`, so content is always sized against the real
|
|
865
|
+
width, however narrow.
|
|
866
|
+
"""
|
|
867
|
+
return max(min_width, get_terminal_width() - 2 * MARGIN_H)
|
|
868
|
+
|
|
869
|
+
def rule(n: int) -> str:
|
|
870
|
+
return "─" * n
|
|
871
|
+
|
|
872
|
+
def bar(pct: int, width: int, color: str = "") -> str:
|
|
873
|
+
color = color or Colors.TEAL
|
|
874
|
+
pct = max(0, min(100, pct))
|
|
875
|
+
eighths = pct * width * 8 // 100
|
|
876
|
+
full, rem = divmod(eighths, 8)
|
|
877
|
+
out = color + "█" * full
|
|
878
|
+
if rem and full < width:
|
|
879
|
+
out += PARTS[rem]
|
|
880
|
+
full += 1
|
|
881
|
+
out += Colors.MUTE + "─" * (width - full) + Colors.R
|
|
882
|
+
return out
|
|
883
|
+
|
|
884
|
+
def rate_of_change(history: list, now: float, value: float, window: float = 20.0, max_len: int = 60):
|
|
885
|
+
"""Tracks `value` over time in `history` (a list of (ts, value) pairs,
|
|
886
|
+
mutated in place and capped to `max_len` entries) and returns its rate
|
|
887
|
+
of change per second, measured from the oldest sample within the last
|
|
888
|
+
`window` seconds. None when there is no earlier sample in that window to
|
|
889
|
+
measure from (the first call, or after a gap longer than `window`), so
|
|
890
|
+
a caller can show "measuring" instead.
|
|
891
|
+
"""
|
|
892
|
+
history.append((now, value))
|
|
893
|
+
del history[:-max_len]
|
|
894
|
+
base_ts = base_val = None
|
|
895
|
+
for ts, v in history:
|
|
896
|
+
if ts >= now - window:
|
|
897
|
+
base_ts, base_val = ts, v
|
|
898
|
+
break
|
|
899
|
+
if base_ts is None or now - base_ts <= 0:
|
|
900
|
+
return None
|
|
901
|
+
return (value - base_val) / (now - base_ts)
|
|
902
|
+
|
|
903
|
+
def sparkline(rate_history: list, rate: float, max_len: int = 14) -> str:
|
|
904
|
+
"""Appends `rate` to `rate_history` (mutated in place, capped to
|
|
905
|
+
`max_len`) and renders it as an 8-level sparkline (SPARK), scaled to the
|
|
906
|
+
largest rate currently in the window.
|
|
907
|
+
|
|
908
|
+
Deliberately a separate rendering from bar(): a bar is a fraction of a
|
|
909
|
+
whole (how full), a sparkline is a trend over time (how fast, lately) -
|
|
910
|
+
conflating the two by drawing both the same way reads as one number
|
|
911
|
+
doubled, not two.
|
|
912
|
+
"""
|
|
913
|
+
rate_history.append(rate)
|
|
914
|
+
del rate_history[:-max_len]
|
|
915
|
+
mx = max(rate_history, default=1) or 1
|
|
916
|
+
return "".join(SPARK[max(0, min(7, int(r / mx * 7.99)))] for r in rate_history)
|
|
917
|
+
|
|
918
|
+
def header_box(left: str, right: str, cols: int, spin: str = "") -> list:
|
|
919
|
+
"""The 3-line rounded header frame (top rule, title row, bottom rule)
|
|
920
|
+
for a live view - `cols` is total frame width, i.e. content_width()'s
|
|
921
|
+
return value. The title row's padding is computed from the *actual*
|
|
922
|
+
rendered pieces (left, right, spin), so the right border always lands
|
|
923
|
+
exactly under the corners no matter how any of the three are sized -
|
|
924
|
+
this single spot is the only place that math needs to be right.
|
|
925
|
+
|
|
926
|
+
Truncates `left` (then `right`, if even that isn't enough) so the row
|
|
927
|
+
never runs past `cols` regardless of terminal width - `right` (typically
|
|
928
|
+
a short, fixed-format clock) is kept whole for as long as it can be;
|
|
929
|
+
`left` (the variable, more compressible piece - a title/library name)
|
|
930
|
+
gives way first.
|
|
931
|
+
|
|
932
|
+
Bakes in its own MARGIN_H left indent (matching every hand-written
|
|
933
|
+
widget line in backbone/prompt/ - e.g. confirm()'s
|
|
934
|
+
f" {message}") rather than relying on a wrapper to add it: a caller
|
|
935
|
+
driving its view through _Widget.render() (prompt/core.py) gets no
|
|
936
|
+
such wrapper, since _Widget only manages the vertical margin itself.
|
|
937
|
+
"""
|
|
938
|
+
interior = cols - 2
|
|
939
|
+
# Reserve the spinner plus one pad column *before* sizing left/right, so
|
|
940
|
+
# truncating to fit `budget` always leaves room for pad >= 1 - flooring
|
|
941
|
+
# pad afterward instead (max(1, ...)) can push the row a column past the
|
|
942
|
+
# border once left+right already exactly fill the interior.
|
|
943
|
+
budget = max(0, interior - len(spin) - 1)
|
|
944
|
+
if visual_len(left) + visual_len(right) > budget:
|
|
945
|
+
right = truncate_text(right, min(visual_len(right), budget))
|
|
946
|
+
left = truncate_text(left, max(0, budget - visual_len(right)))
|
|
947
|
+
pad = max(0, interior - visual_len(left) - visual_len(right) - len(spin))
|
|
948
|
+
C = Colors
|
|
949
|
+
hpad = " " * MARGIN_H
|
|
950
|
+
return [
|
|
951
|
+
f"{hpad}{C.FRAME}╭{rule(interior)}╮{C.R}",
|
|
952
|
+
f"{hpad}{C.FRAME}│{C.B}{left}{C.R}{' ' * pad}{C.TXT}{right}{C.R}{spin}{C.FRAME}│{C.R}",
|
|
953
|
+
f"{hpad}{C.FRAME}╰{rule(interior)}╯{C.R}",
|
|
954
|
+
]
|
|
955
|
+
|
|
956
|
+
def wrap_margins(lines: list, width: int = None) -> str:
|
|
957
|
+
"""Applies the global MARGIN_H/MARGIN_V inset plus per-line
|
|
958
|
+
clear-to-end-of-line, ready for one `sys.stdout.write` - the standard
|
|
959
|
+
back* frame render (pair with an `ESC[H` cursor-home beforehand).
|
|
960
|
+
|
|
961
|
+
Joins with \\r\\n, not \\n: raw terminal mode (tty.setraw, used by
|
|
962
|
+
backbone.prompt.core for key reading) clears OPOST, so the terminal
|
|
963
|
+
stops translating a bare \\n into a carriage return - every line after
|
|
964
|
+
the first would otherwise start wherever the previous one ended instead
|
|
965
|
+
of column 1.
|
|
966
|
+
|
|
967
|
+
`width` (typically content_width()'s return value), if given, clips
|
|
968
|
+
every line to it first - a safety net so one field a caller forgot to
|
|
969
|
+
size itself can't overflow the whole frame. Hand-tuned per-field
|
|
970
|
+
truncation still reads better (an ellipsis where it makes sense, not a
|
|
971
|
+
hard cut mid-word); this is the guarantee behind it, not a replacement.
|
|
972
|
+
"""
|
|
973
|
+
if width is not None:
|
|
974
|
+
lines = [clip_ansi(line, width) for line in lines]
|
|
975
|
+
hpad = " " * MARGIN_H
|
|
976
|
+
vpad = ["\033[K"] * MARGIN_V
|
|
977
|
+
out = vpad + [hpad + line + "\033[K" for line in lines] + vpad
|
|
978
|
+
return "\r\n".join(out)
|
|
979
|
+
|
|
980
|
+
def spinner(frame: int) -> str:
|
|
981
|
+
return SPIN[frame % len(SPIN)]
|
|
982
|
+
|
|
983
|
+
def human_gb(kb: float) -> str:
|
|
984
|
+
"""`kb` (kilobytes) as a one-decimal GB string, e.g. "3.5"."""
|
|
985
|
+
return f"{kb / 1048576:.1f}"
|
|
986
|
+
|
|
987
|
+
def dir_size_kb(path) -> int:
|
|
988
|
+
"""Total size of every file under `path`, in KB (0 if it doesn't exist)."""
|
|
989
|
+
path = Path(path)
|
|
990
|
+
total = 0
|
|
991
|
+
if path.exists():
|
|
992
|
+
for f in path.rglob("*"):
|
|
993
|
+
if f.is_file():
|
|
994
|
+
try:
|
|
995
|
+
total += f.stat().st_size
|
|
996
|
+
except OSError:
|
|
997
|
+
pass
|
|
998
|
+
return total // 1024
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
_ANSI_DEMO = re.compile(r"\033\[[0-9;]*[a-zA-Z]")
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _demo() -> None:
|
|
1005
|
+
"""Self-check for the pure logic here, header_box's alignment above all.
|
|
1006
|
+
Runs without a terminal: `python3 -m backbone.ui`.
|
|
1007
|
+
"""
|
|
1008
|
+
for cols in (70, 100, 137):
|
|
1009
|
+
for left, right, spin in ((" SHORT", "12:00:00 ", "X"), ("", "", ""), ("a" * 20, "b", "Y")):
|
|
1010
|
+
top, mid, bot = header_box(left, right, cols, spin)
|
|
1011
|
+
widths = {len(strip_ansi(top)), len(strip_ansi(mid)), len(strip_ansi(bot))}
|
|
1012
|
+
assert len(widths) == 1, (cols, left, right, spin, widths)
|
|
1013
|
+
# A left piece far longer than the frame must truncate, not overflow -
|
|
1014
|
+
# the border still lines up at a width too narrow for it whole.
|
|
1015
|
+
for cols in (10, 20, 40):
|
|
1016
|
+
top, mid, bot = header_box("a" * 200, "12:00:00 ", cols, "X")
|
|
1017
|
+
widths = {len(strip_ansi(top)), len(strip_ansi(mid)), len(strip_ansi(bot))}
|
|
1018
|
+
assert len(widths) == 1, (cols, widths)
|
|
1019
|
+
assert len(strip_ansi(mid)) == cols + MARGIN_H, (cols, len(strip_ansi(mid)))
|
|
1020
|
+
assert visual_len("plain") == 5
|
|
1021
|
+
assert visual_len(f"{Colors.BOLD}x{Colors.RESET}") == 1
|
|
1022
|
+
assert truncate_text("abcdefgh", 4) == "abc…"
|
|
1023
|
+
assert plural(1, "disc") == "1 disc" and plural(2, "disc") == "2 discs"
|
|
1024
|
+
# Regression guard: wrap_margins must join with \r\n, not \n - under raw
|
|
1025
|
+
# terminal mode (OPOST cleared) a bare \n never returns to column 1, and
|
|
1026
|
+
# every line after the first starts wherever the previous one ended.
|
|
1027
|
+
assert "\r\n" in wrap_margins(["a", "b"])
|
|
1028
|
+
# wrap_margins(width=...) must clip an oversized line rather than let it
|
|
1029
|
+
# overflow - the safety net behind every tool's own per-field sizing.
|
|
1030
|
+
clipped = wrap_margins(["a" * 200], width=10).split("\r\n")[1]
|
|
1031
|
+
assert len(strip_ansi(clipped)) <= 10 + MARGIN_H, clipped
|
|
1032
|
+
assert human_gb(1048576) == "1.0"
|
|
1033
|
+
hist = []
|
|
1034
|
+
assert rate_of_change(hist, 0.0, 0) is None # first sample - no window yet
|
|
1035
|
+
assert rate_of_change(hist, 10.0, 1024 * 10) == 1024.0 # 10240 KB over 10s = 1024 KB/s
|
|
1036
|
+
rh = []
|
|
1037
|
+
spark1 = sparkline(rh, 10.0)
|
|
1038
|
+
spark2 = sparkline(rh, 20.0)
|
|
1039
|
+
assert len(spark1) == 1 and len(spark2) == 2
|
|
1040
|
+
print("backbone.ui self-check OK")
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
if __name__ == "__main__":
|
|
1044
|
+
_demo()
|