nabla-cli 0.6.3__tar.gz → 0.6.4__tar.gz
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.
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/PKG-INFO +1 -1
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/__init__.py +1 -1
- nabla_cli-0.6.4/nabla/branding.py +139 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/cli.py +8 -2
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/recorder.py +8 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/ui.py +53 -44
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla_cli.egg-info/PKG-INFO +1 -1
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/pyproject.toml +1 -1
- nabla_cli-0.6.3/nabla/branding.py +0 -101
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/README.md +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/__main__.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/config.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/contract.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/induce.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/profile.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/samplegen.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/scanner.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla/term.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla_cli.egg-info/SOURCES.txt +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla_cli.egg-info/requires.txt +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/setup.cfg +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_cli_ux.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_config.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_induce.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_recorder.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_scanner.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.6.4}/tests/test_schema_resolver.py +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""nabla terminal branding: the ∇ logo and startup banner.
|
|
2
|
+
|
|
3
|
+
The banner prints only on a real terminal — piped output, tests, and
|
|
4
|
+
--json consumers never see it. Block glyphs render via the Windows
|
|
5
|
+
console's UTF-16 writer, so no legacy-codepage fallback is needed on an
|
|
6
|
+
interactive console.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
|
|
16
|
+
# Uniform thick strokes like the wordmark: solid bar, solid diagonals,
|
|
17
|
+
# single-block apex. Terminal cells are ~2:1 tall, so 6 rows x 11 cols
|
|
18
|
+
# reads close to the mark's near-equilateral proportions.
|
|
19
|
+
_LOGO = [
|
|
20
|
+
"█▀▀▀▀▀▀▀▀▀█",
|
|
21
|
+
" █ █",
|
|
22
|
+
" █ █",
|
|
23
|
+
" █ █",
|
|
24
|
+
" █ █",
|
|
25
|
+
" █",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
_BRAND_STYLE = "bold cyan"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def banner_lines() -> list[tuple[str, str, str]]:
|
|
32
|
+
"""(logo_line, tagline, tagline_style) triples; outer rows have no tagline."""
|
|
33
|
+
taglines = [
|
|
34
|
+
("", ""),
|
|
35
|
+
("nabla", "bold"),
|
|
36
|
+
(f"v{__version__} · OpenAI call-site harvester", ""),
|
|
37
|
+
("scan · capture · build", "cyan"),
|
|
38
|
+
("", ""),
|
|
39
|
+
("", ""),
|
|
40
|
+
]
|
|
41
|
+
return [(logo, text, style) for logo, (text, style) in zip(_LOGO, taglines)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _compose() -> Text:
|
|
45
|
+
"""The complete banner, final form."""
|
|
46
|
+
frame = Text()
|
|
47
|
+
for logo_line, tagline, tag_style in banner_lines():
|
|
48
|
+
frame.append(f" {logo_line:<14}", style=_BRAND_STYLE)
|
|
49
|
+
if tagline:
|
|
50
|
+
frame.append(tagline, style=tag_style)
|
|
51
|
+
frame.append("\n")
|
|
52
|
+
return frame
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _stroke_frames() -> list[set[tuple[int, int]]]:
|
|
56
|
+
"""Reveal order that traces the mark like a pen: the top bar sweeps
|
|
57
|
+
left to right, then the two diagonals descend together to the apex."""
|
|
58
|
+
frames: list[set[tuple[int, int]]] = []
|
|
59
|
+
mask: set[tuple[int, int]] = set()
|
|
60
|
+
top_cells = [(0, c) for c, ch in enumerate(_LOGO[0]) if ch != " "]
|
|
61
|
+
for i in range(0, len(top_cells), 2):
|
|
62
|
+
mask.update(top_cells[i:i + 2])
|
|
63
|
+
frames.append(set(mask))
|
|
64
|
+
for r in range(1, len(_LOGO)):
|
|
65
|
+
mask.update((r, c) for c, ch in enumerate(_LOGO[r]) if ch != " ")
|
|
66
|
+
frames.append(set(mask))
|
|
67
|
+
return frames
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def print_banner(console: Console | None = None) -> None:
|
|
71
|
+
"""Print the complete banner instantly. Animation, when wanted, happens
|
|
72
|
+
afterwards via animate_in_place() — over this already-rendered region —
|
|
73
|
+
so downstream UI (rails, menus) is on screen before a single frame."""
|
|
74
|
+
console = console or Console()
|
|
75
|
+
if not console.is_terminal:
|
|
76
|
+
return
|
|
77
|
+
console.print()
|
|
78
|
+
console.print(_compose(), end="")
|
|
79
|
+
console.print()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Raw ANSI for the in-place replay: rich Live owns whole-screen regions,
|
|
83
|
+
# but here the cursor must slip above content that is already printed
|
|
84
|
+
# below the banner, so escape codes are driven directly.
|
|
85
|
+
_ANSI = {"brand": "\x1b[1;36m", "bold": "\x1b[1m", "cyan": "\x1b[36m",
|
|
86
|
+
"": "", "reset": "\x1b[0m"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ansi_row(r: int, mask: set[tuple[int, int]]) -> str:
|
|
90
|
+
logo_line, tagline, tag_style = banner_lines()[r]
|
|
91
|
+
revealed = "".join(
|
|
92
|
+
ch if ch == " " or (r, c) in mask else " "
|
|
93
|
+
for c, ch in enumerate(logo_line)
|
|
94
|
+
)
|
|
95
|
+
row = f" {_ANSI['brand']}{revealed:<14}{_ANSI['reset']}"
|
|
96
|
+
if tagline:
|
|
97
|
+
row += f"{_ANSI[tag_style]}{tagline}{_ANSI['reset']}"
|
|
98
|
+
return row + "\x1b[0K" # clear any residue right of the rewritten text
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def animate_in_place(console: Console, lines_below: int) -> None:
|
|
102
|
+
"""Replay the logo's stroke-order draw over the banner already on
|
|
103
|
+
screen. ``lines_below`` = rows printed after the banner's trailing
|
|
104
|
+
blank line (rails, menu, ...); the cursor is assumed to sit at the
|
|
105
|
+
start of the row just below them. Everything is rewritten line-for-line
|
|
106
|
+
(taglines included), so the layout cannot shift."""
|
|
107
|
+
if not console.is_terminal:
|
|
108
|
+
return
|
|
109
|
+
import sys as _sys
|
|
110
|
+
import time
|
|
111
|
+
|
|
112
|
+
up = len(_LOGO) + 1 + lines_below # +1: banner's trailing blank line
|
|
113
|
+
write = _sys.stdout.write
|
|
114
|
+
try:
|
|
115
|
+
for mask in _stroke_frames():
|
|
116
|
+
write(f"\x1b[s\x1b[{up}A") # save cursor, jump to logo top
|
|
117
|
+
for r in range(len(_LOGO)):
|
|
118
|
+
write("\r" + _ansi_row(r, mask))
|
|
119
|
+
if r < len(_LOGO) - 1:
|
|
120
|
+
write("\x1b[1B") # down one row
|
|
121
|
+
write("\x1b[u") # restore cursor
|
|
122
|
+
_sys.stdout.flush()
|
|
123
|
+
time.sleep(0.09)
|
|
124
|
+
except Exception: # noqa: BLE001 - cosmetic replay must never break init
|
|
125
|
+
pass
|
|
126
|
+
finally:
|
|
127
|
+
# Terminal state is guaranteed sane: land on the finished mark.
|
|
128
|
+
try:
|
|
129
|
+
full = {(r, c) for r, line in enumerate(_LOGO)
|
|
130
|
+
for c, ch in enumerate(line) if ch != " "}
|
|
131
|
+
write(f"\x1b[s\x1b[{up}A")
|
|
132
|
+
for r in range(len(_LOGO)):
|
|
133
|
+
write("\r" + _ansi_row(r, full))
|
|
134
|
+
if r < len(_LOGO) - 1:
|
|
135
|
+
write("\x1b[1B")
|
|
136
|
+
write("\x1b[u")
|
|
137
|
+
_sys.stdout.flush()
|
|
138
|
+
except Exception: # noqa: BLE001
|
|
139
|
+
pass
|
|
@@ -264,9 +264,12 @@ def _read_api_key(prompt: str) -> str:
|
|
|
264
264
|
|
|
265
265
|
|
|
266
266
|
def _cmd_init(args) -> int:
|
|
267
|
+
from .branding import animate_in_place
|
|
267
268
|
from .ui import RAIL, rail, rail_end, rail_ok, rail_start, select
|
|
268
269
|
|
|
269
|
-
|
|
270
|
+
# Everything renders first — banner (final form), rail, menu — then the
|
|
271
|
+
# logo replays its stroke-order draw in place. Nothing ever shifts.
|
|
272
|
+
print_banner(_out)
|
|
270
273
|
rail_start(_out, "nabla init")
|
|
271
274
|
rail(_out)
|
|
272
275
|
|
|
@@ -274,7 +277,10 @@ def _cmd_init(args) -> int:
|
|
|
274
277
|
if provider is None:
|
|
275
278
|
names = list(PROVIDERS)
|
|
276
279
|
options = [(name, _PROVIDER_DESCRIPTIONS.get(name, "")) for name in names]
|
|
277
|
-
provider = names[select(
|
|
280
|
+
provider = names[select(
|
|
281
|
+
_out, "Provider", options,
|
|
282
|
+
on_first_draw=lambda menu_lines: animate_in_place(_out, menu_lines + 2),
|
|
283
|
+
)]
|
|
278
284
|
else:
|
|
279
285
|
rail(_out, f"Provider {provider}")
|
|
280
286
|
|
|
@@ -167,6 +167,14 @@ def _forward_to_openai_responder(body: dict, headers: dict) -> dict:
|
|
|
167
167
|
and isinstance(rf, dict) and rf.get("type") == "json_schema"
|
|
168
168
|
and ("schema" in detail.lower() or "invalid" in detail.lower())):
|
|
169
169
|
body = {**body, "response_format": {"type": "json_object"}}
|
|
170
|
+
# Providers require the word "json" in the messages to use
|
|
171
|
+
# json_object mode. This nudge goes only to the stand-in —
|
|
172
|
+
# the proxy captured the original request before forwarding,
|
|
173
|
+
# so the recorded prompt is untouched.
|
|
174
|
+
messages = list(body.get("messages", []))
|
|
175
|
+
if "json" not in json.dumps(messages).lower():
|
|
176
|
+
messages.append({"role": "system", "content": "Respond in JSON."})
|
|
177
|
+
body = {**body, "messages": messages}
|
|
170
178
|
fell_back = True
|
|
171
179
|
print("nabla capture: stand-in oracle rejected this site's json_schema — "
|
|
172
180
|
"retrying in json_object mode (the profile still carries the real schema)",
|
|
@@ -67,9 +67,13 @@ def rail_end(console: Console, text: str, style: str = "cyan") -> None:
|
|
|
67
67
|
# ---------------------------------------------------------------------------
|
|
68
68
|
|
|
69
69
|
def select(console: Console, label: str, options: list[tuple[str, str]],
|
|
70
|
-
default: int = 0) -> int:
|
|
70
|
+
default: int = 0, on_first_draw=None) -> int:
|
|
71
71
|
"""Pick one of ``options`` ([(name, description), ...]); returns the index.
|
|
72
72
|
|
|
73
|
+
``on_first_draw(lines_drawn)`` fires once after the menu is fully on
|
|
74
|
+
screen, before the first key is read — the hook that lets a banner
|
|
75
|
+
animation replay above an already-complete layout.
|
|
76
|
+
|
|
73
77
|
Interactive Windows console: arrow keys + enter, pointer rendering,
|
|
74
78
|
menu collapses to a single confirmation line after the choice.
|
|
75
79
|
Anywhere else: numbered prompt.
|
|
@@ -123,6 +127,11 @@ def select(console: Console, label: str, options: list[tuple[str, str]],
|
|
|
123
127
|
lines_drawn = len(options) + 1
|
|
124
128
|
|
|
125
129
|
draw()
|
|
130
|
+
if on_first_draw is not None:
|
|
131
|
+
try:
|
|
132
|
+
on_first_draw(lines_drawn)
|
|
133
|
+
except Exception: # noqa: BLE001 - a cosmetic hook must never break input
|
|
134
|
+
pass
|
|
126
135
|
while True:
|
|
127
136
|
ch = msvcrt.getwch()
|
|
128
137
|
if ch in ("\r", "\n"):
|
|
@@ -267,46 +276,46 @@ def make_progress(console: Console, unit: str = "samples") -> Progress:
|
|
|
267
276
|
console=console,
|
|
268
277
|
transient=True,
|
|
269
278
|
)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
def prompt_digits(console: Console, label: str, default: int) -> int:
|
|
273
|
-
"""Numeric input that cannot go wrong: on an interactive Windows console
|
|
274
|
-
it reads keys raw — digits echo, backspace works, everything else
|
|
275
|
-
(arrows, brackets, console line-editing artifacts) is ignored. Elsewhere
|
|
276
|
-
it strips non-digits from a plain input()."""
|
|
277
|
-
prompt = Text(f"{RAIL} ", style=_RAIL_STYLE)
|
|
278
|
-
prompt.append(label, style="bold")
|
|
279
|
-
prompt.append(f" [{default}]", style="cyan")
|
|
280
|
-
prompt.append(" ")
|
|
281
|
-
|
|
282
|
-
interactive = (
|
|
283
|
-
sys.platform == "win32"
|
|
284
|
-
and console.is_terminal
|
|
285
|
-
and sys.stdin.isatty()
|
|
286
|
-
)
|
|
287
|
-
if not interactive:
|
|
288
|
-
console.print(prompt, end="")
|
|
289
|
-
raw = "".join(ch for ch in input() if ch.isdigit())
|
|
290
|
-
return int(raw) if raw else default
|
|
291
|
-
|
|
292
|
-
import msvcrt
|
|
293
|
-
|
|
294
|
-
console.print(prompt, end="")
|
|
295
|
-
chars: list[str] = []
|
|
296
|
-
while True:
|
|
297
|
-
ch = msvcrt.getwch()
|
|
298
|
-
if ch in ("\r", "\n"):
|
|
299
|
-
print()
|
|
300
|
-
break
|
|
301
|
-
if ch == "\x03":
|
|
302
|
-
raise KeyboardInterrupt
|
|
303
|
-
if ch == "\b":
|
|
304
|
-
if chars:
|
|
305
|
-
chars.pop()
|
|
306
|
-
print("\b \b", end="", flush=True)
|
|
307
|
-
elif ch.isdigit():
|
|
308
|
-
chars.append(ch)
|
|
309
|
-
print(ch, end="", flush=True)
|
|
310
|
-
elif ch in ("\xe0", "\x00"):
|
|
311
|
-
msvcrt.getwch() # swallow the arrow/function-key payload
|
|
312
|
-
return int("".join(chars)) if chars else default
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def prompt_digits(console: Console, label: str, default: int) -> int:
|
|
282
|
+
"""Numeric input that cannot go wrong: on an interactive Windows console
|
|
283
|
+
it reads keys raw — digits echo, backspace works, everything else
|
|
284
|
+
(arrows, brackets, console line-editing artifacts) is ignored. Elsewhere
|
|
285
|
+
it strips non-digits from a plain input()."""
|
|
286
|
+
prompt = Text(f"{RAIL} ", style=_RAIL_STYLE)
|
|
287
|
+
prompt.append(label, style="bold")
|
|
288
|
+
prompt.append(f" [{default}]", style="cyan")
|
|
289
|
+
prompt.append(" ")
|
|
290
|
+
|
|
291
|
+
interactive = (
|
|
292
|
+
sys.platform == "win32"
|
|
293
|
+
and console.is_terminal
|
|
294
|
+
and sys.stdin.isatty()
|
|
295
|
+
)
|
|
296
|
+
if not interactive:
|
|
297
|
+
console.print(prompt, end="")
|
|
298
|
+
raw = "".join(ch for ch in input() if ch.isdigit())
|
|
299
|
+
return int(raw) if raw else default
|
|
300
|
+
|
|
301
|
+
import msvcrt
|
|
302
|
+
|
|
303
|
+
console.print(prompt, end="")
|
|
304
|
+
chars: list[str] = []
|
|
305
|
+
while True:
|
|
306
|
+
ch = msvcrt.getwch()
|
|
307
|
+
if ch in ("\r", "\n"):
|
|
308
|
+
print()
|
|
309
|
+
break
|
|
310
|
+
if ch == "\x03":
|
|
311
|
+
raise KeyboardInterrupt
|
|
312
|
+
if ch == "\b":
|
|
313
|
+
if chars:
|
|
314
|
+
chars.pop()
|
|
315
|
+
print("\b \b", end="", flush=True)
|
|
316
|
+
elif ch.isdigit():
|
|
317
|
+
chars.append(ch)
|
|
318
|
+
print(ch, end="", flush=True)
|
|
319
|
+
elif ch in ("\xe0", "\x00"):
|
|
320
|
+
msvcrt.getwch() # swallow the arrow/function-key payload
|
|
321
|
+
return int("".join(chars)) if chars else default
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
"""nabla terminal branding: the ∇ logo and startup banner.
|
|
2
|
-
|
|
3
|
-
The banner prints only on a real terminal — piped output, tests, and
|
|
4
|
-
--json consumers never see it. Block glyphs render via the Windows
|
|
5
|
-
console's UTF-16 writer, so no legacy-codepage fallback is needed on an
|
|
6
|
-
interactive console.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
from rich.console import Console
|
|
12
|
-
from rich.text import Text
|
|
13
|
-
|
|
14
|
-
from . import __version__
|
|
15
|
-
|
|
16
|
-
# Uniform thick strokes like the wordmark: solid bar, solid diagonals,
|
|
17
|
-
# single-block apex. Terminal cells are ~2:1 tall, so 6 rows x 11 cols
|
|
18
|
-
# reads close to the mark's near-equilateral proportions.
|
|
19
|
-
_LOGO = [
|
|
20
|
-
"█▀▀▀▀▀▀▀▀▀█",
|
|
21
|
-
" █ █",
|
|
22
|
-
" █ █",
|
|
23
|
-
" █ █",
|
|
24
|
-
" █ █",
|
|
25
|
-
" █",
|
|
26
|
-
]
|
|
27
|
-
|
|
28
|
-
_BRAND_STYLE = "bold cyan"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def banner_lines() -> list[tuple[str, str, str]]:
|
|
32
|
-
"""(logo_line, tagline, tagline_style) triples; outer rows have no tagline."""
|
|
33
|
-
taglines = [
|
|
34
|
-
("", ""),
|
|
35
|
-
("nabla", "bold"),
|
|
36
|
-
(f"v{__version__} · OpenAI call-site harvester", ""),
|
|
37
|
-
("scan · capture · build", "cyan"),
|
|
38
|
-
("", ""),
|
|
39
|
-
("", ""),
|
|
40
|
-
]
|
|
41
|
-
return [(logo, text, style) for logo, (text, style) in zip(_LOGO, taglines)]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _compose(traced: set[tuple[int, int]] | None = None) -> Text:
|
|
45
|
-
"""The full banner. With ``traced`` given, every glyph is still present
|
|
46
|
-
(no layout shift, ever) — untraced stroke cells just render quieter, and
|
|
47
|
-
the traced set brightens as the animation sweeps through."""
|
|
48
|
-
frame = Text()
|
|
49
|
-
for r, (logo_line, tagline, tag_style) in enumerate(banner_lines()):
|
|
50
|
-
frame.append(" ")
|
|
51
|
-
for c, ch in enumerate(f"{logo_line:<14}"):
|
|
52
|
-
if ch == " ":
|
|
53
|
-
frame.append(" ")
|
|
54
|
-
elif traced is None or (r, c) in traced:
|
|
55
|
-
frame.append(ch, style=_BRAND_STYLE)
|
|
56
|
-
else:
|
|
57
|
-
frame.append(ch, style="dim cyan")
|
|
58
|
-
if tagline:
|
|
59
|
-
frame.append(tagline, style=tag_style)
|
|
60
|
-
frame.append("\n")
|
|
61
|
-
return frame
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _stroke_frames() -> list[set[tuple[int, int]]]:
|
|
65
|
-
"""Reveal order that traces the mark like a pen: the top bar sweeps
|
|
66
|
-
left to right, then the two diagonals descend together to the apex."""
|
|
67
|
-
frames: list[set[tuple[int, int]]] = []
|
|
68
|
-
mask: set[tuple[int, int]] = set()
|
|
69
|
-
top_cells = [(0, c) for c, ch in enumerate(_LOGO[0]) if ch != " "]
|
|
70
|
-
for i in range(0, len(top_cells), 2):
|
|
71
|
-
mask.update(top_cells[i:i + 2])
|
|
72
|
-
frames.append(set(mask))
|
|
73
|
-
for r in range(1, len(_LOGO)):
|
|
74
|
-
mask.update((r, c) for c, ch in enumerate(_LOGO[r]) if ch != " ")
|
|
75
|
-
frames.append(set(mask))
|
|
76
|
-
return frames
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def print_banner(console: Console | None = None, animate: bool = False) -> None:
|
|
80
|
-
console = console or Console()
|
|
81
|
-
if not console.is_terminal:
|
|
82
|
-
return
|
|
83
|
-
console.print()
|
|
84
|
-
if animate:
|
|
85
|
-
import time
|
|
86
|
-
|
|
87
|
-
from rich.live import Live
|
|
88
|
-
|
|
89
|
-
# Full-size first frame: the complete banner is on screen before a
|
|
90
|
-
# single animation tick, so the layout never shifts — the sweep only
|
|
91
|
-
# changes brightness along the stroke.
|
|
92
|
-
with Live(_compose(traced=set()), console=console,
|
|
93
|
-
refresh_per_second=30, transient=False) as live:
|
|
94
|
-
time.sleep(0.12)
|
|
95
|
-
for traced in _stroke_frames():
|
|
96
|
-
live.update(_compose(traced=traced))
|
|
97
|
-
time.sleep(0.09)
|
|
98
|
-
live.update(_compose())
|
|
99
|
-
else:
|
|
100
|
-
console.print(_compose(), end="")
|
|
101
|
-
console.print()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|