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/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()