kdcube-cli 0.0.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.
- kdcube_cli/__init__.py +1 -0
- kdcube_cli/banner.py +476 -0
- kdcube_cli/cli.py +5468 -0
- kdcube_cli/export_live_bundles.py +500 -0
- kdcube_cli/frontend_config.py +387 -0
- kdcube_cli/installer.py +4959 -0
- kdcube_cli/tty_keys.py +74 -0
- kdcube_cli-0.0.0.dist-info/METADATA +5 -0
- kdcube_cli-0.0.0.dist-info/RECORD +12 -0
- kdcube_cli-0.0.0.dist-info/WHEEL +5 -0
- kdcube_cli-0.0.0.dist-info/licenses/LICENSE +21 -0
- kdcube_cli-0.0.0.dist-info/top_level.txt +1 -0
kdcube_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
kdcube_cli/banner.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
|
|
6
|
+
# Enable ANSI escape sequences on Windows
|
|
7
|
+
if os.name == 'nt':
|
|
8
|
+
os.system('color')
|
|
9
|
+
|
|
10
|
+
# --- Terminal color capability detection -----------------------------------
|
|
11
|
+
#
|
|
12
|
+
# macOS Terminal.app supports 256 colors but NOT 24-bit truecolor.
|
|
13
|
+
# Modern terminals (VS Code, JetBrains, iTerm2, Windows Terminal, Linux)
|
|
14
|
+
# advertise truecolor support via COLORTERM=truecolor or COLORTERM=24bit.
|
|
15
|
+
#
|
|
16
|
+
# Detection priority:
|
|
17
|
+
# 1. COLORTERM=truecolor / 24bit → truecolor confirmed
|
|
18
|
+
# 2. TERM_PROGRAM=Apple_Terminal → 256-color fallback
|
|
19
|
+
# 3. Anything else → assume truecolor (safe for modern terminals)
|
|
20
|
+
|
|
21
|
+
def _detect_truecolor() -> bool:
|
|
22
|
+
"""Return True if the terminal supports 24-bit truecolor ANSI escape codes."""
|
|
23
|
+
colorterm = os.environ.get("COLORTERM", "").lower()
|
|
24
|
+
if colorterm in ("truecolor", "24bit"):
|
|
25
|
+
return True
|
|
26
|
+
if os.environ.get("TERM_PROGRAM") == "Apple_Terminal":
|
|
27
|
+
return False
|
|
28
|
+
return True # VS Code, JetBrains, iTerm2, Windows Terminal, etc.
|
|
29
|
+
|
|
30
|
+
_TRUECOLOR = _detect_truecolor()
|
|
31
|
+
|
|
32
|
+
# --- 256-color fallback helper ----------------------------------------------
|
|
33
|
+
_CUBE_VALS = [0, 95, 135, 175, 215, 255]
|
|
34
|
+
|
|
35
|
+
def _rgb_to_256(r: int, g: int, b: int) -> int:
|
|
36
|
+
"""Convert an RGB triple to the nearest ANSI 256-color palette index."""
|
|
37
|
+
def _nearest(v):
|
|
38
|
+
return min(range(6), key=lambda i: abs(_CUBE_VALS[i] - v))
|
|
39
|
+
|
|
40
|
+
ri, gi, bi = _nearest(r), _nearest(g), _nearest(b)
|
|
41
|
+
cube_idx = 16 + 36 * ri + 6 * gi + bi
|
|
42
|
+
cr, cg, cb = _CUBE_VALS[ri], _CUBE_VALS[gi], _CUBE_VALS[bi]
|
|
43
|
+
cube_dist = (cr - r) ** 2 + (cg - g) ** 2 + (cb - b) ** 2
|
|
44
|
+
|
|
45
|
+
# Also compare against the 24-step grayscale ramp (indices 232–255)
|
|
46
|
+
luma = int(0.299 * r + 0.587 * g + 0.114 * b)
|
|
47
|
+
gray_n = max(0, min(23, round((luma - 8) / 10)))
|
|
48
|
+
gray_idx = 232 + gray_n
|
|
49
|
+
gv = 8 + 10 * gray_n
|
|
50
|
+
gray_dist = (gv - r) ** 2 + (gv - g) ** 2 + (gv - b) ** 2
|
|
51
|
+
|
|
52
|
+
return cube_idx if cube_dist <= gray_dist else gray_idx
|
|
53
|
+
|
|
54
|
+
# --- ANSI Truecolor Definitions ---
|
|
55
|
+
RESET = "\033[0m"
|
|
56
|
+
RESET_BG = "\033[49m" # reset background only (keep foreground)
|
|
57
|
+
|
|
58
|
+
def rgb_fg(r, g, b):
|
|
59
|
+
if _TRUECOLOR:
|
|
60
|
+
return f"\033[38;2;{r};{g};{b}m"
|
|
61
|
+
return f"\033[38;5;{_rgb_to_256(r, g, b)}m"
|
|
62
|
+
|
|
63
|
+
def rgb_bg(r, g, b):
|
|
64
|
+
if _TRUECOLOR:
|
|
65
|
+
return f"\033[48;2;{r};{g};{b}m"
|
|
66
|
+
return f"\033[48;5;{_rgb_to_256(r, g, b)}m"
|
|
67
|
+
|
|
68
|
+
# Background-color palette (main robot + label)
|
|
69
|
+
COLORS = {
|
|
70
|
+
"0": RESET,
|
|
71
|
+
"1": rgb_bg(198, 243, 241), # Light Cyan
|
|
72
|
+
"2": rgb_bg(1, 190, 178), # Teal
|
|
73
|
+
"3": rgb_bg(67, 114, 195), # Blue
|
|
74
|
+
"4": rgb_bg(255, 255, 255), # White
|
|
75
|
+
"5": rgb_bg(6, 16, 30), # Dark Navy
|
|
76
|
+
"6": rgb_bg(107, 99, 254), # Purple
|
|
77
|
+
"t": rgb_bg(198, 243, 241), # label top face (light cyan)
|
|
78
|
+
"f": rgb_bg(1, 190, 178), # label front face (teal)
|
|
79
|
+
"s": rgb_bg(67, 114, 195), # label side face (blue)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Foreground-color palette (half-block upper pixel)
|
|
83
|
+
FG = {
|
|
84
|
+
"0": "", # transparent — no colour set
|
|
85
|
+
"1": rgb_fg(198, 243, 241),
|
|
86
|
+
"2": rgb_fg(1, 190, 178),
|
|
87
|
+
"3": rgb_fg(67, 114, 195),
|
|
88
|
+
"4": rgb_fg(255, 255, 255),
|
|
89
|
+
"5": rgb_fg(6, 16, 30),
|
|
90
|
+
"6": rgb_fg(107, 99, 254),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
def _halfblock_char(top: str, bot: str) -> str:
|
|
94
|
+
"""
|
|
95
|
+
Combine two pixel values into one terminal character using half-blocks.
|
|
96
|
+
▀ (U+2580) = upper half filled with FG colour, lower half = BG colour
|
|
97
|
+
▄ (U+2584) = lower half filled with FG colour, upper half = BG colour
|
|
98
|
+
Each resulting glyph is exactly 1 char wide × ½ row tall → square pixel.
|
|
99
|
+
"""
|
|
100
|
+
if top == "0" and bot == "0":
|
|
101
|
+
return RESET + " "
|
|
102
|
+
if top != "0" and bot == "0":
|
|
103
|
+
return FG[top] + RESET_BG + "▀" + RESET
|
|
104
|
+
if top == "0" and bot != "0":
|
|
105
|
+
return FG[bot] + RESET_BG + "▄" + RESET
|
|
106
|
+
# both coloured: upper half = top (fg), lower half = bot (bg)
|
|
107
|
+
return FG[top] + COLORS[bot] + "▀" + RESET
|
|
108
|
+
|
|
109
|
+
# --- Block font (all glyphs are 7 rows tall) ---
|
|
110
|
+
# Uppercase = full 7-row cap height.
|
|
111
|
+
# Lowercase = x-height letters sit at the bottom; top rows are blank ("0000").
|
|
112
|
+
# 'b' has an ascender so it uses all 7 rows like a capital.
|
|
113
|
+
FONT_3D = {
|
|
114
|
+
# ── uppercase ──────────────────────────────────────────────────────────
|
|
115
|
+
"K": [
|
|
116
|
+
"10001",
|
|
117
|
+
"10010",
|
|
118
|
+
"10100",
|
|
119
|
+
"11000",
|
|
120
|
+
"10100",
|
|
121
|
+
"10010",
|
|
122
|
+
"10001",
|
|
123
|
+
],
|
|
124
|
+
"D": [
|
|
125
|
+
"11110",
|
|
126
|
+
"10001",
|
|
127
|
+
"10001",
|
|
128
|
+
"10001",
|
|
129
|
+
"10001",
|
|
130
|
+
"10001",
|
|
131
|
+
"11110",
|
|
132
|
+
],
|
|
133
|
+
"C": [
|
|
134
|
+
"01111",
|
|
135
|
+
"10000",
|
|
136
|
+
"10000",
|
|
137
|
+
"10000",
|
|
138
|
+
"10000",
|
|
139
|
+
"10000",
|
|
140
|
+
"01111",
|
|
141
|
+
],
|
|
142
|
+
# ── lowercase (x-height: top 3 rows blank, letter occupies rows 3-6) ──
|
|
143
|
+
"u": [
|
|
144
|
+
"0000",
|
|
145
|
+
"0000",
|
|
146
|
+
"1001", # starts at row 2 — in sync with 'e' and bottom of 'b'
|
|
147
|
+
"1001",
|
|
148
|
+
"1001",
|
|
149
|
+
"1001",
|
|
150
|
+
"0111",
|
|
151
|
+
],
|
|
152
|
+
"b": [ # ascender → full 7 rows
|
|
153
|
+
"1000",
|
|
154
|
+
"1000",
|
|
155
|
+
"1110",
|
|
156
|
+
"1001",
|
|
157
|
+
"1001",
|
|
158
|
+
"1001",
|
|
159
|
+
"1110",
|
|
160
|
+
],
|
|
161
|
+
"e": [
|
|
162
|
+
"00000",
|
|
163
|
+
"00000",
|
|
164
|
+
"01110", # top arc
|
|
165
|
+
"10001", # inner opening ← the visible hole of the 'e'
|
|
166
|
+
"11110", # crossbar (open right — distinguishes 'e' from 'c')
|
|
167
|
+
"10000", # left stem
|
|
168
|
+
"01110", # bottom arc
|
|
169
|
+
],
|
|
170
|
+
}
|
|
171
|
+
FONT_3D_GAP = 2 # columns of blank space between letters
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --- Pixel Art Grids (16 columns x 13 rows) ---
|
|
175
|
+
BOX_ROBOT = [
|
|
176
|
+
"0000100000006000",
|
|
177
|
+
"0000300000006000",
|
|
178
|
+
"0000300000006000",
|
|
179
|
+
"0011111111111100",
|
|
180
|
+
"0011111111111100",
|
|
181
|
+
"0022222222222200",
|
|
182
|
+
"0024442224442200",
|
|
183
|
+
"3324542224542233",
|
|
184
|
+
"3324442224442233",
|
|
185
|
+
"0022222222222200",
|
|
186
|
+
"0022255555222200",
|
|
187
|
+
"0022222222222200",
|
|
188
|
+
"0033333333333300",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
ISO_ROBOT = [
|
|
192
|
+
"0000100000060000",
|
|
193
|
+
"0000300000060000",
|
|
194
|
+
"0000300000060000",
|
|
195
|
+
"0000111111110000",
|
|
196
|
+
"0001111111122000",
|
|
197
|
+
"0011111111222200",
|
|
198
|
+
"0033222222222200",
|
|
199
|
+
"0033244224422233",
|
|
200
|
+
"0033245224522233",
|
|
201
|
+
"0033222222222200",
|
|
202
|
+
"0033222555222200",
|
|
203
|
+
"0033222222222200",
|
|
204
|
+
"0000333333333300",
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --- Mini Robot (8 cols × 8 pixel-rows, rendered via half-blocks) -----------
|
|
209
|
+
# Half-block rendering packs 2 pixel-rows into 1 terminal row.
|
|
210
|
+
# 8 pixel-cols × 8 pixel-rows → 8 terminal chars × 4 terminal rows.
|
|
211
|
+
# At ~2:1 char aspect ratio each half-block pixel is square → 1:1 robot.
|
|
212
|
+
# Features pinned to cols 1 & 6 (symmetric).
|
|
213
|
+
# Pairing plan (each pair → 1 terminal row via half-blocks):
|
|
214
|
+
# pair (0,1): antenna tips on transparent + empty → tiny ▀ marks floating above head
|
|
215
|
+
# pair (2,3): solid head + solid head → fully SOLID light-cyan bar
|
|
216
|
+
# pair (4,5): white eyes + dark pupils → eye+pupil in one half-char height
|
|
217
|
+
# pair (6,7): teal body + blue legs → body top, leg stubs at cols 1 & 6
|
|
218
|
+
MINI_ROBOT = [
|
|
219
|
+
# Half-block pairing: every 2 pixel rows → 1 terminal row.
|
|
220
|
+
# Same-colour pairs → solid BG space (no ▀ seam artefact).
|
|
221
|
+
# Mixed-colour pairs → ▀ half-block (upper=FG, lower=BG).
|
|
222
|
+
#
|
|
223
|
+
# Rule: same colour in both rows of a pair → solid BG space (no ▀, no gaps).
|
|
224
|
+
# different colours → ▀ half-block (eyes only).
|
|
225
|
+
#
|
|
226
|
+
# Layout (14 pixel rows → 7 terminal rows):
|
|
227
|
+
# pair (0,1) : SOLID light-cyan tip — LEFT antenna only (col1); right = transparent
|
|
228
|
+
# pair (2,3) : SOLID shafts — blue(3) col1, purple(6) col6 (two distinct colours)
|
|
229
|
+
# pair (4,5) : SOLID light-cyan head bar
|
|
230
|
+
# pair (6,7) : 2-wide eyes cols 1-2 & 5-6 — white upper / dark lower
|
|
231
|
+
# pair (8,9) : SOLID dark(5) mouth — 2 pixels centered at cols 3-4
|
|
232
|
+
# pair (10,11): SOLID teal body
|
|
233
|
+
# pair (12,13): SOLID blue leg squares — cols 1 & 6
|
|
234
|
+
"01000000", # row 0 — left tip: light-cyan(1) col1 only, right antenna has no tip
|
|
235
|
+
"01000000", # row 1 — same → SOLID light-cyan tip (left only)
|
|
236
|
+
"03000060", # row 2 — shafts: blue(3) col1, purple(6) col6
|
|
237
|
+
"03000060", # row 3 — same → SOLID shafts, distinct colours
|
|
238
|
+
"11111111", # row 4 — head
|
|
239
|
+
"11111111", # row 5 — same → SOLID light-cyan
|
|
240
|
+
"24422442", # row 6 — face: white(4) eyes at cols 1-2 & 5-6
|
|
241
|
+
"25522552", # row 7 — dark(5) pupils → ▀ white upper / dark lower
|
|
242
|
+
"22255222", # row 8 — mouth: dark(5) at cols 3-4 (centered smile)
|
|
243
|
+
"22222222", # row 9 — same → SOLID dark mouth
|
|
244
|
+
"22222222", # row 10 — body
|
|
245
|
+
"22222222", # row 11 — same → SOLID teal
|
|
246
|
+
"03000030", # row 12 — legs: blue(3) at cols 1 & 6
|
|
247
|
+
"03000030", # row 13 — same → SOLID blue legs
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _build_front_mask(text: str) -> list:
|
|
252
|
+
"""Build a 2-D binary mask (list-of-lists) for the given text string."""
|
|
253
|
+
rows = [[] for _ in range(7)]
|
|
254
|
+
first = True
|
|
255
|
+
for ch in text: # preserve case so lowercase glyphs are looked up correctly
|
|
256
|
+
glyph = FONT_3D.get(ch)
|
|
257
|
+
if glyph is None:
|
|
258
|
+
continue
|
|
259
|
+
if not first:
|
|
260
|
+
for r in range(7):
|
|
261
|
+
rows[r].extend([0] * FONT_3D_GAP)
|
|
262
|
+
first = False
|
|
263
|
+
for r in range(7):
|
|
264
|
+
rows[r].extend([1 if px == "1" else 0 for px in glyph[r]])
|
|
265
|
+
return rows
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _build_label_3d(text: str) -> tuple:
|
|
269
|
+
"""
|
|
270
|
+
Render 'text' as a 3-D extruded pixel label.
|
|
271
|
+
|
|
272
|
+
Each lit pixel becomes a tiny cube viewed from upper-left:
|
|
273
|
+
• front face ('f') – main teal colour, drawn at (x, y)
|
|
274
|
+
• top face ('t') – light cyan, drawn at (x, y-1) only for
|
|
275
|
+
pixels whose top neighbour is empty
|
|
276
|
+
• side face ('s') – blue, drawn at (x+1, y) only for
|
|
277
|
+
pixels whose right neighbour is empty
|
|
278
|
+
|
|
279
|
+
Draw order: side → top → front (front is never overwritten).
|
|
280
|
+
Returns (bitmap_rows, baseline_row).
|
|
281
|
+
"""
|
|
282
|
+
front = _build_front_mask(text)
|
|
283
|
+
h = len(front)
|
|
284
|
+
w = len(front[0]) if front else 0
|
|
285
|
+
|
|
286
|
+
cells: dict = {}
|
|
287
|
+
|
|
288
|
+
# ── side faces (right-edge pixels only) ──────────────────────────────────
|
|
289
|
+
for y in range(h):
|
|
290
|
+
for x in range(w):
|
|
291
|
+
if front[y][x]:
|
|
292
|
+
right_empty = (x + 1 >= w) or (not front[y][x + 1])
|
|
293
|
+
if right_empty:
|
|
294
|
+
cells[(x + 1, y)] = "s"
|
|
295
|
+
|
|
296
|
+
# ── top faces (top-edge pixels only) ─────────────────────────────────────
|
|
297
|
+
for y in range(h):
|
|
298
|
+
for x in range(w):
|
|
299
|
+
if front[y][x]:
|
|
300
|
+
top_empty = (y - 1 < 0) or (not front[y - 1][x])
|
|
301
|
+
if top_empty:
|
|
302
|
+
# only place top face if that cell isn't already a front face
|
|
303
|
+
if (x, y - 1) not in cells or cells[(x, y - 1)] != "f":
|
|
304
|
+
cells[(x, y - 1)] = "t"
|
|
305
|
+
|
|
306
|
+
# ── front faces (drawn last – highest priority) ───────────────────────────
|
|
307
|
+
for y in range(h):
|
|
308
|
+
for x in range(w):
|
|
309
|
+
if front[y][x]:
|
|
310
|
+
cells[(x, y)] = "f"
|
|
311
|
+
|
|
312
|
+
if not cells:
|
|
313
|
+
return [], 0
|
|
314
|
+
|
|
315
|
+
min_x = min(x for x, _ in cells)
|
|
316
|
+
max_x = max(x for x, _ in cells)
|
|
317
|
+
min_y = min(y for _, y in cells)
|
|
318
|
+
max_y = max(y for _, y in cells)
|
|
319
|
+
|
|
320
|
+
out_w = max_x - min_x + 1
|
|
321
|
+
out_h = max_y - min_y + 1
|
|
322
|
+
canvas = [["." for _ in range(out_w)] for _ in range(out_h)]
|
|
323
|
+
|
|
324
|
+
for (x, y), key in cells.items():
|
|
325
|
+
canvas[y - min_y][x - min_x] = key
|
|
326
|
+
|
|
327
|
+
# baseline = row index of the bottom of the front faces inside the bitmap
|
|
328
|
+
baseline_row = (h - 1) - min_y
|
|
329
|
+
return ["".join(row) for row in canvas], baseline_row
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _trim_bitmap_rows(rows: list[str], blank: str = "0") -> list[str]:
|
|
333
|
+
if not rows:
|
|
334
|
+
return rows
|
|
335
|
+
min_x = None
|
|
336
|
+
max_x = None
|
|
337
|
+
for row in rows:
|
|
338
|
+
for idx, ch in enumerate(row):
|
|
339
|
+
if ch != blank:
|
|
340
|
+
min_x = idx if min_x is None else min(min_x, idx)
|
|
341
|
+
max_x = idx if max_x is None else max(max_x, idx)
|
|
342
|
+
if min_x is None or max_x is None:
|
|
343
|
+
return rows
|
|
344
|
+
return [row[min_x : max_x + 1] for row in rows]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _terminal_columns(default: int = 120) -> int:
|
|
348
|
+
try:
|
|
349
|
+
return shutil.get_terminal_size((default, 24)).columns
|
|
350
|
+
except Exception:
|
|
351
|
+
return default
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _banner_layout(
|
|
355
|
+
label_width: int,
|
|
356
|
+
main_width: int,
|
|
357
|
+
mini_width: int,
|
|
358
|
+
) -> tuple[str, str, str]:
|
|
359
|
+
"""Choose a pixel width that fits the current terminal."""
|
|
360
|
+
columns = _terminal_columns()
|
|
361
|
+
|
|
362
|
+
full_width_double = label_width * 2 + main_width * 2 + 2 + mini_width
|
|
363
|
+
full_width_single = label_width + main_width + 2 + mini_width
|
|
364
|
+
|
|
365
|
+
if columns >= full_width_double:
|
|
366
|
+
return "full", " ", " "
|
|
367
|
+
if columns >= full_width_single:
|
|
368
|
+
return "full", " ", " "
|
|
369
|
+
return "stacked", " ", " "
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _render_label_row(row: str, pixel_fill: str, pixel_blank: str) -> str:
|
|
373
|
+
parts: list[str] = []
|
|
374
|
+
for ch in row:
|
|
375
|
+
if ch == ".":
|
|
376
|
+
parts.append(pixel_blank)
|
|
377
|
+
else:
|
|
378
|
+
parts.append(COLORS[ch] + pixel_fill + RESET)
|
|
379
|
+
return "".join(parts)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _center_pad(rendered_width: int) -> str:
|
|
383
|
+
columns = _terminal_columns()
|
|
384
|
+
return " " * max(0, (columns - rendered_width) // 2)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def print_cli_banner():
|
|
388
|
+
"""Print the KDCube label, main robot, and mini robot side-by-side."""
|
|
389
|
+
selected_robot = _trim_bitmap_rows(random.choice([BOX_ROBOT, ISO_ROBOT]))
|
|
390
|
+
robot_height = len(selected_robot)
|
|
391
|
+
|
|
392
|
+
label_bitmap, label_baseline = _build_label_3d("KDCube")
|
|
393
|
+
label_height = len(label_bitmap)
|
|
394
|
+
label_width = len(label_bitmap[0]) if label_bitmap else 0
|
|
395
|
+
|
|
396
|
+
mini_pix_rows = len(MINI_ROBOT) # pixel rows (8)
|
|
397
|
+
mini_term_rows = (mini_pix_rows + 1) // 2 # terminal rows (4, via half-blocks)
|
|
398
|
+
mini_width = len(MINI_ROBOT[0])
|
|
399
|
+
main_width = len(selected_robot[0]) if selected_robot else 0
|
|
400
|
+
layout_mode, pixel_fill, pixel_blank = _banner_layout(label_width, main_width, mini_width)
|
|
401
|
+
|
|
402
|
+
# Align label baseline with the last robot row
|
|
403
|
+
text_start = (robot_height - 1) - label_baseline
|
|
404
|
+
# Align mini robot bottom with main robot bottom
|
|
405
|
+
mini_start = robot_height - mini_term_rows
|
|
406
|
+
|
|
407
|
+
sys.stdout.write("\n")
|
|
408
|
+
|
|
409
|
+
if layout_mode == "stacked":
|
|
410
|
+
if label_bitmap:
|
|
411
|
+
label_render_width = label_width * len(pixel_fill)
|
|
412
|
+
label_pad = _center_pad(label_render_width)
|
|
413
|
+
for row in label_bitmap:
|
|
414
|
+
sys.stdout.write(label_pad + _render_label_row(row, pixel_fill, pixel_blank) + "\n")
|
|
415
|
+
sys.stdout.write("\n")
|
|
416
|
+
|
|
417
|
+
robot_render_width = main_width * len(pixel_fill) + 2 + mini_width
|
|
418
|
+
robot_pad = _center_pad(robot_render_width)
|
|
419
|
+
for i in range(robot_height):
|
|
420
|
+
sys.stdout.write(robot_pad)
|
|
421
|
+
|
|
422
|
+
for pixel in selected_robot[i]:
|
|
423
|
+
if pixel == "0":
|
|
424
|
+
sys.stdout.write(RESET + pixel_blank)
|
|
425
|
+
else:
|
|
426
|
+
sys.stdout.write(COLORS[pixel] + pixel_fill + RESET)
|
|
427
|
+
|
|
428
|
+
sys.stdout.write(" ")
|
|
429
|
+
term_rel = i - mini_start
|
|
430
|
+
if 0 <= term_rel < mini_term_rows:
|
|
431
|
+
pix = term_rel * 2
|
|
432
|
+
row_top = MINI_ROBOT[pix]
|
|
433
|
+
row_bot = MINI_ROBOT[pix + 1] if pix + 1 < mini_pix_rows else "0" * mini_width
|
|
434
|
+
for t, b in zip(row_top, row_bot):
|
|
435
|
+
sys.stdout.write(_halfblock_char(t, b))
|
|
436
|
+
else:
|
|
437
|
+
sys.stdout.write(" " * mini_width)
|
|
438
|
+
sys.stdout.write("\n")
|
|
439
|
+
|
|
440
|
+
sys.stdout.write("\n")
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
for i in range(robot_height):
|
|
444
|
+
# ── 3-D label ────────────────────────────────────────────────────────
|
|
445
|
+
rel = i - text_start
|
|
446
|
+
if 0 <= rel < label_height:
|
|
447
|
+
sys.stdout.write(_render_label_row(label_bitmap[rel], pixel_fill, pixel_blank))
|
|
448
|
+
else:
|
|
449
|
+
sys.stdout.write(pixel_blank * label_width)
|
|
450
|
+
|
|
451
|
+
# ── main robot ───────────────────────────────────────────────────────
|
|
452
|
+
for pixel in selected_robot[i]:
|
|
453
|
+
if pixel == "0":
|
|
454
|
+
sys.stdout.write(RESET + pixel_blank)
|
|
455
|
+
else:
|
|
456
|
+
sys.stdout.write(COLORS[pixel] + pixel_fill + RESET)
|
|
457
|
+
|
|
458
|
+
# ── mini robot (half-block, 1:1 square pixels) ───────────────────────
|
|
459
|
+
sys.stdout.write(" ")
|
|
460
|
+
term_rel = i - mini_start
|
|
461
|
+
if 0 <= term_rel < mini_term_rows:
|
|
462
|
+
pix = term_rel * 2
|
|
463
|
+
row_top = MINI_ROBOT[pix]
|
|
464
|
+
row_bot = MINI_ROBOT[pix + 1] if pix + 1 < mini_pix_rows else "0" * mini_width
|
|
465
|
+
for t, b in zip(row_top, row_bot):
|
|
466
|
+
sys.stdout.write(_halfblock_char(t, b))
|
|
467
|
+
else:
|
|
468
|
+
sys.stdout.write(" " * mini_width)
|
|
469
|
+
|
|
470
|
+
sys.stdout.write("\n")
|
|
471
|
+
|
|
472
|
+
sys.stdout.write("\n")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
if __name__ == "__main__":
|
|
476
|
+
print_cli_banner()
|