multi-puzzle-solver 1.0.7__py3-none-any.whl → 1.0.9__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.

Potentially problematic release.


This version of multi-puzzle-solver might be problematic. Click here for more details.

@@ -1,561 +1,565 @@
1
- import numpy as np
2
- from typing import Callable, Optional, List, Sequence, Literal
3
- from puzzle_solver.core.utils import Pos, get_all_pos, get_next_pos, in_bounds, set_char, get_char, Direction
4
-
5
-
6
- def combined_function(V: int,
7
- H: int,
8
- cell_flags: Optional[Callable[[int, int], str]] = None,
9
- is_shaded: Optional[Callable[[int, int], bool]] = None,
10
- center_char: Optional[Callable[[int, int], str]] = None,
11
- special_content: Optional[Callable[[int, int], str]] = None,
12
- text_on_shaded_cells: bool = True,
13
- scale_x: int = 2,
14
- scale_y: int = 1,
15
- show_axes: bool = True,
16
- show_grid: bool = True,
17
- ) -> str:
18
- """
19
- Render a V x H grid that can:
20
- • draw selective edges per cell via cell_flags(r, c) containing any of 'U','D','L','R'
21
- • shade cells via is_shaded(r, c)
22
- • place centered text per cell via center_char(r, c)
23
- • draw interior arms via special_content(r, c) returning any combo of 'U','D','L','R'
24
- (e.g., 'UR', 'DL', 'ULRD', or '' to leave the interior unchanged).
25
- Arms extend from the cell’s interior center toward the indicated sides.
26
- • horizontal stretch (>=1). Interior width per cell = 2*scale_x - 1 (default 2)
27
- • vertical stretch (>=1). Interior height per cell = scale_y (default 1)
28
- • show_axes: bool = True, show the axes (columns on top, rows on the left).
29
- • show_grid: bool = True, show the grid lines.
30
-
31
- Behavior:
32
- - If cell_flags is None, draws a full grid (all interior and outer borders present).
33
- - Shading is applied first, borders are drawn on top, and center text is drawn last unless text_on_shaded_cells is False in which case the text is not drawn on shaded cells.
34
- - Axes are shown (columns on top, rows on the left).
35
-
36
- Draw order:
37
- 1) shading
38
- 2) borders
39
- 3) special_content (interior line arms)
40
- 4) center_char (unless text_on_shaded_cells=False and cell is shaded)
41
- """
42
- assert V >= 1 and H >= 1, f'V and H must be >= 1, got {V} and {H}'
43
- assert cell_flags is None or callable(cell_flags), f'cell_flags must be None or callable, got {cell_flags}'
44
- assert is_shaded is None or callable(is_shaded), f'is_shaded must be None or callable, got {is_shaded}'
45
- assert center_char is None or callable(center_char), f'center_char must be None or callable, got {center_char}'
46
- assert special_content is None or callable(special_content), f'special_content must be None or callable, got {special_content}'
47
-
48
- # Rendering constants
49
- fill_char: str = '▒' # single char for shaded interiors
50
- empty_char: str = ' ' # single char for unshaded interiors
51
-
52
- assert scale_x >= 1 and scale_y >= 1
53
- assert len(fill_char) == 1 and len(empty_char) == 1
54
-
55
- # ── Layout helpers ─────────────────────────────────────────────────────
56
- def x_corner(c: int) -> int: # column of vertical border at grid column c (0..H)
57
- return (2 * c) * scale_x
58
- def y_border(r: int) -> int: # row of horizontal border at grid row r (0..V)
59
- return (scale_y + 1) * r
60
-
61
- rows = y_border(V) + 1
62
- cols = x_corner(H) + 1
63
- canvas = [[empty_char] * cols for _ in range(rows)]
64
-
65
- # ── Edge presence arrays derived from cell_flags ──
66
- # H_edges[r, c] is the horizontal edge between rows r and r+1 above column segment c (shape: (V+1, H))
67
- # V_edges[r, c] is the vertical edge between cols c and c+1 left of row segment r (shape: (V, H+1))
68
- if cell_flags is None:
69
- # Full grid: all horizontal and vertical segments are present
70
- H_edges = [[show_grid for _ in range(H)] for _ in range(V + 1)]
71
- V_edges = [[show_grid for _ in range(H + 1)] for _ in range(V)]
72
- else:
73
- H_edges = [[False for _ in range(H)] for _ in range(V + 1)]
74
- V_edges = [[False for _ in range(H + 1)] for _ in range(V)]
75
- for r in range(V):
76
- for c in range(H):
77
- s = cell_flags(r, c) or ''
78
- if 'U' in s:
79
- H_edges[r ][c] = True
80
- if 'D' in s:
81
- H_edges[r + 1][c] = True
82
- if 'L' in s:
83
- V_edges[r][c ] = True
84
- if 'R' in s:
85
- V_edges[r][c + 1] = True
86
-
87
- # ── Shading first (borders will overwrite) ─────────────────────────────
88
- shaded_map = [[False]*H for _ in range(V)]
89
- for r in range(V):
90
- top = y_border(r) + 1
91
- bottom = y_border(r + 1) - 1 # inclusive
92
- if top > bottom:
93
- continue
94
- for c in range(H):
95
- left = x_corner(c) + 1
96
- right = x_corner(c + 1) - 1 # inclusive
97
- if left > right:
98
- continue
99
- shaded = bool(is_shaded(r, c)) if callable(is_shaded) else False
100
- shaded_map[r][c] = shaded
101
- ch = fill_char if shaded else empty_char
102
- for yy in range(top, bottom + 1):
103
- for xx in range(left, right + 1):
104
- canvas[yy][xx] = ch
105
-
106
- # ── Grid lines (respect edge presence) ─────────────────────────────────
107
- U, Rb, D, Lb = 1, 2, 4, 8
108
- JUNCTION = {
109
- 0: ' ',
110
- U: '│', D: '│', U | D: '│',
111
- Lb: '─', Rb: '─', Lb | Rb: '─',
112
- U | Rb: '└', Rb | D: '┌', D | Lb: '┐', Lb | U: '┘',
113
- U | D | Lb: '┤', U | D | Rb: '├', Lb | Rb | U: '┴', Lb | Rb | D: '┬',
114
- U | Rb | D | Lb: '┼',
115
- }
116
-
117
- # Horizontal segments
118
- for r in range(V + 1):
119
- yy = y_border(r)
120
- for c in range(H):
121
- if H_edges[r][c]:
122
- base = x_corner(c)
123
- for k in range(1, 2 * scale_x): # 1..(2*scale_x-1)
124
- canvas[yy][base + k] = '─'
125
-
126
- # Vertical segments
127
- for r in range(V):
128
- for c in range(H + 1):
129
- if V_edges[r][c]:
130
- xx = x_corner(c)
131
- for ky in range(1, scale_y + 1):
132
- canvas[y_border(r) + ky][xx] = '│'
133
-
134
- # Junctions at intersections
135
- for r in range(V + 1):
136
- yy = y_border(r)
137
- for c in range(H + 1):
138
- xx = x_corner(c)
139
- m = 0
140
- if r > 0 and V_edges[r - 1][c]:
141
- m |= U
142
- if r < V and V_edges[r][c]:
143
- m |= D
144
- if c > 0 and H_edges[r][c - 1]:
145
- m |= Lb
146
- if c < H and H_edges[r][c]:
147
- m |= Rb
148
- canvas[yy][xx] = JUNCTION[m]
149
-
150
- # ── Special interior content (arms) + cross-cell bridges ──────────────
151
- def draw_special_arms(r_cell: int, c_cell: int, code: Optional[str]):
152
- if not code:
153
- return
154
- s = set(code)
155
- # interior box
156
- left = x_corner(c_cell) + 1
157
- right = x_corner(c_cell + 1) - 1
158
- top = y_border(r_cell) + 1
159
- bottom= y_border(r_cell + 1) - 1
160
- if left > right or top > bottom:
161
- return
162
-
163
- # center of interior
164
- cx = left + (right - left) // 2
165
- cy = top + (bottom - top) // 2
166
-
167
- # draw arms out from center (keep inside interior; don't touch borders)
168
- if 'U' in s and cy - 1 >= top:
169
- for yy in range(cy - 1, top - 1, -1):
170
- canvas[yy][cx] = '│'
171
- if 'D' in s and cy + 1 <= bottom:
172
- for yy in range(cy + 1, bottom + 1):
173
- canvas[yy][cx] = '│'
174
- if 'L' in s and cx - 1 >= left:
175
- for xx in range(cx - 1, left - 1, -1):
176
- canvas[cy][xx] = '─'
177
- if 'R' in s and cx + 1 <= right:
178
- for xx in range(cx + 1, right + 1):
179
- canvas[cy][xx] = '─'
180
- if '/' in s:
181
- for xx in range(right - left + 1):
182
- for yy in range(top - bottom + 1):
183
- canvas[top + yy][left + xx] = '/'
184
- if '\\' in s:
185
- for xx in range(right - left + 1):
186
- for yy in range(top - bottom + 1):
187
- canvas[top + yy][left + xx] = '\\'
188
-
189
- # center junction
190
- U_b, R_b, D_b, L_b = 1, 2, 4, 8
191
- m = 0
192
- if 'U' in s: m |= U_b
193
- if 'D' in s: m |= D_b
194
- if 'L' in s: m |= L_b
195
- if 'R' in s: m |= R_b
196
- canvas[cy][cx] = JUNCTION.get(m, ' ')
197
-
198
- # pass 1: draw interior arms per cell
199
- special_map = [[set() for _ in range(H)] for _ in range(V)]
200
- if callable(special_content):
201
- for r in range(V):
202
- for c in range(H):
203
- flags = set(ch for ch in str(special_content(r, c) or ''))
204
- special_map[r][c] = flags
205
- if flags:
206
- draw_special_arms(r, c, ''.join(flags))
207
-
208
- # ── Center text (drawn last so it sits atop shading/arms) ─────────────
209
- def put_center_text(r_cell: int, c_cell: int, s: Optional[str]):
210
- if s is None:
211
- return
212
- s = str(s)
213
- # interior box
214
- left = x_corner(c_cell) + 1
215
- right = x_corner(c_cell + 1) - 1
216
- top = y_border(r_cell) + 1
217
- bottom= y_border(r_cell + 1) - 1
218
- if left > right or top > bottom:
219
- return
220
- span_w = right - left + 1
221
- yy = top + (bottom - top) // 2
222
- if len(s) > span_w:
223
- s = s[:span_w] # truncate to protect borders
224
- start = left + (span_w - len(s)) // 2
225
- for i, ch in enumerate(s):
226
- canvas[yy][start + i] = ch
227
-
228
-
229
- # helper to get interior-center coordinates
230
- def _cell_center_rc(r_cell: int, c_cell: int):
231
- left = x_corner(c_cell) + 1
232
- right = x_corner(c_cell + 1) - 1
233
- top = y_border(r_cell) + 1
234
- bottom= y_border(r_cell + 1) - 1
235
- if left > right or top > bottom:
236
- return None
237
- cx = left + (right - left) // 2
238
- cy = top + (bottom - top) // 2
239
- return cy, cx
240
-
241
- # ── REPLACE your place_connector() and "pass 2" with the following ────
242
-
243
- # PASS 2: merge/bridge on every border using bitmasks (works with or without borders)
244
- if callable(special_content):
245
- # vertical borders: c in [0..H], between (r,c-1) and (r,c)
246
- for r in range(V):
247
- # y (row) where we draw the junction on this border: the interior center row
248
- cc = _cell_center_rc(r, 0)
249
- if cc is None:
250
- continue
251
- cy = cc[0]
252
- for c in range(H + 1):
253
- x = x_corner(c)
254
- mask = 0
255
- # base: if the vertical grid line exists here, add U and D
256
- if V_edges[r][c]:
257
- mask |= U | D
258
-
259
- # neighbors pointing toward this vertical border
260
- left_flags = special_map[r][c - 1] if c - 1 >= 0 else set()
261
- right_flags = special_map[r][c] if c < H else set()
262
- if 'R' in left_flags:
263
- mask |= Lb
264
- if 'L' in right_flags:
265
- mask |= Rb
266
-
267
- # nothing to draw? leave whatever is already there
268
- if mask == 0:
269
- continue
270
- canvas[cy][x] = JUNCTION[mask]
271
-
272
- # horizontal borders: r in [0..V], between (r-1,c) and (r,c)
273
- for c in range(H):
274
- # x (col) where we draw the junction on this border: the interior center col
275
- cc = _cell_center_rc(0, c)
276
- if cc is None:
277
- continue
278
- cx = cc[1]
279
- for r in range(V + 1):
280
- y = y_border(r)
281
- mask = 0
282
- # base: if the horizontal grid line exists here, add L and R
283
- if r <= V - 1 and H_edges[r][c]: # H_edges indexed [0..V] x [0..H-1]
284
- mask |= Lb | Rb
285
-
286
- # neighbors pointing toward this horizontal border
287
- up_flags = special_map[r - 1][c] if r - 1 >= 0 else set()
288
- down_flags = special_map[r][c] if r < V else set()
289
- if 'D' in up_flags:
290
- mask |= U
291
- if 'U' in down_flags:
292
- mask |= D
293
-
294
- if mask == 0:
295
- continue
296
- canvas[y][cx] = JUNCTION[mask]
297
-
298
- if callable(center_char):
299
- for r in range(V):
300
- for c in range(H):
301
- if not text_on_shaded_cells and shaded_map[r][c]:
302
- continue
303
- if not text_on_shaded_cells and special_map[r][c]:
304
- continue
305
- put_center_text(r, c, center_char(r, c))
306
-
307
- # ── Stringify with axes ────────────────────────────────────────────────
308
- art_rows = [''.join(row) for row in canvas]
309
- if not show_axes:
310
- return '\n'.join(art_rows)
311
-
312
- # Axes labels: columns on top; rows on left
313
- gut = max(2, len(str(V - 1)))
314
- gutter = ' ' * gut
315
- top_tens = list(gutter + ' ' * cols)
316
- top_ones = list(gutter + ' ' * cols)
317
- for c in range(H):
318
- xc_center = x_corner(c) + scale_x
319
- if H >= 10:
320
- top_tens[gut + xc_center] = str((c // 10) % 10)
321
- top_ones[gut + xc_center] = str(c % 10)
322
- if gut >= 2:
323
- top_tens[gut - 2:gut] = list(' ')
324
- top_ones[gut - 2:gut] = list(' ')
325
-
326
- labeled = []
327
- for y, line in enumerate(art_rows):
328
- mod = y % (scale_y + 1)
329
- if 1 <= mod <= scale_y:
330
- r = y // (scale_y + 1)
331
- mid = (scale_y + 1) // 2
332
- label = (str(r).rjust(gut) if mod == mid else ' ' * gut)
333
- else:
334
- label = ' ' * gut
335
- labeled.append(label + line)
336
-
337
- return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
338
-
339
- def id_board_to_wall_fn(id_board: np.array, border_is_wall = True) -> Callable[[int, int], str]:
340
- """In many instances, we have a 2d array where cell values are arbitrary ids
341
- and we want to convert it to a 2d array where cell values are walls "U", "D", "L", "R" to represent the edges that separate me from my neighbors that have different ids.
342
- Args:
343
- id_board: np.array of shape (N, N) with arbitrary ids.
344
- border_is_wall: if True, the edges of the board are considered to be walls.
345
- Returns:
346
- Callable[[int, int], str] that returns the walls "U", "D", "L", "R" for the cell at (r, c).
347
- """
348
- res = np.full((id_board.shape[0], id_board.shape[1]), '', dtype=object)
349
- V, H = id_board.shape
350
- def append_char(pos: Pos, s: str):
351
- set_char(res, pos, get_char(res, pos) + s)
352
- def handle_pos_direction(pos: Pos, direction: Direction, s: str):
353
- pos2 = get_next_pos(pos, direction)
354
- if in_bounds(pos2, V, H):
355
- if get_char(id_board, pos2) != get_char(id_board, pos):
356
- append_char(pos, s)
357
- else:
358
- if border_is_wall:
359
- append_char(pos, s)
360
- for pos in get_all_pos(V, H):
361
- handle_pos_direction(pos, Direction.LEFT, 'L')
362
- handle_pos_direction(pos, Direction.RIGHT, 'R')
363
- handle_pos_direction(pos, Direction.UP, 'U')
364
- handle_pos_direction(pos, Direction.DOWN, 'D')
365
- return lambda r, c: res[r][c]
366
-
367
- CellVal = Literal["B", "W", "TL", "TR", "BL", "BR"]
368
- GridLike = Sequence[Sequence[CellVal]]
369
-
370
- def render_bw_tiles_split(
371
- grid: GridLike,
372
- cell_w: int = 6,
373
- cell_h: int = 3,
374
- borders: bool = False,
375
- mode: Literal["ansi", "text"] = "ansi",
376
- text_palette: Literal["solid", "hatch"] = "solid",
377
- cell_text: Optional[Callable[[int, int], str]] = None) -> str:
378
- """
379
- Render a VxH grid with '/' or '\\' splits and optional per-cell centered text.
380
-
381
- `cell_text(r, c) -> str`: if returns non-empty, its first character is drawn
382
- near the geometric center of cell (r,c), nudged to the black side for halves.
383
- """
384
-
385
- V = len(grid)
386
- if V == 0:
387
- return ""
388
- H = len(grid[0])
389
- if any(len(row) != H for row in grid):
390
- raise ValueError("All rows must have the same length")
391
- if cell_w < 1 or cell_h < 1:
392
- raise ValueError("cell_w and cell_h must be >= 1")
393
-
394
- allowed = {"B","W","TL","TR","BL","BR"}
395
- for r in range(V):
396
- for c in range(H):
397
- if grid[r][c] not in allowed:
398
- raise ValueError(f"Invalid cell value at ({r},{c}): {grid[r][c]}")
399
-
400
- # ── Mode setup ─────────────────────────────────────────────────────────
401
- use_color = (mode == "ansi")
402
-
403
- def sgr(bg: int | None = None, fg: int | None = None) -> str:
404
- if not use_color:
405
- return ""
406
- parts = []
407
- if fg is not None:
408
- parts.append(str(fg))
409
- if bg is not None:
410
- parts.append(str(bg))
411
- return ("\x1b[" + ";".join(parts) + "m") if parts else ""
412
-
413
- RESET = "\x1b[0m" if use_color else ""
414
-
415
- BG_BLACK, BG_WHITE = 40, 47
416
- FG_BLACK, FG_WHITE = 30, 37
417
-
418
- if text_palette == "solid":
419
- TXT_BLACK, TXT_WHITE = " ", "█"
420
- elif text_palette == "hatch":
421
- TXT_BLACK, TXT_WHITE = "░", "▓"
422
- else:
423
- raise ValueError("text_palette must be 'solid' or 'hatch'")
424
-
425
- def diag_kind_and_slash(val: CellVal):
426
- if val in ("TR", "BL"):
427
- return "main", "\\"
428
- elif val in ("TL", "BR"):
429
- return "anti", "/"
430
- return None, "?"
431
-
432
- def is_black(val: CellVal, fx: float, fy: float) -> bool:
433
- if val == "B":
434
- return True
435
- if val == "W":
436
- return False
437
- kind, _ = diag_kind_and_slash(val)
438
- if kind == "main": # y = x
439
- return (fy < fx) if val == "TR" else (fy > fx)
440
- else: # y = 1 - x
441
- return (fy < 1 - fx) if val == "TL" else (fy > 1 - fx)
442
-
443
- # Build one tile as a matrix of 1-char tokens (already colorized if ANSI)
444
- def make_tile(val: CellVal) -> List[List[str]]:
445
- rows: List[List[str]] = []
446
- _, slash_ch = diag_kind_and_slash(val)
447
- for y in range(cell_h):
448
- fy = (y + 0.5) / cell_h
449
- line: List[str] = []
450
- prev = None
451
- for x in range(cell_w):
452
- fx = (x + 0.5) / cell_w
453
- fx_next = (x + 1.5) / cell_w
454
-
455
- if val == "B":
456
- line.append(sgr(bg=BG_BLACK) + " " + RESET if use_color else TXT_BLACK)
457
- continue
458
- if val == "W":
459
- line.append(sgr(bg=BG_WHITE) + " " + RESET if use_color else TXT_WHITE)
460
- continue
461
-
462
- black_side = is_black(val, fx, fy)
463
- next_black_side = is_black(val, fx_next, fy)
464
- boundary = False # if true places a "/" or "\" at the current position
465
- if prev is not None and not prev and black_side: # prev white and cur black => boundary now
466
- boundary = True
467
- if black_side and not next_black_side: # cur black and next white => boundary now
468
- boundary = True
469
-
470
- if use_color:
471
- bg = BG_BLACK if black_side else BG_WHITE
472
- if boundary:
473
- fg = FG_WHITE if bg == BG_BLACK else FG_BLACK
474
- line.append(sgr(bg=bg, fg=fg) + slash_ch + RESET)
475
- else:
476
- line.append(sgr(bg=bg) + " " + RESET)
477
- else:
478
- if boundary:
479
- line.append(slash_ch)
480
- else:
481
- line.append(TXT_BLACK if black_side else TXT_WHITE)
482
- prev = black_side
483
- rows.append(line)
484
- return rows
485
-
486
- # Overlay a single character centered (nudged into black side if needed)
487
- def overlay_center_char(tile: List[List[str]], val: CellVal, ch: str):
488
- if not ch:
489
- return
490
- ch = ch[0] # keep one character (user said single number)
491
- cx, cy = cell_w // 2, cell_h // 2
492
- cx -= 1
493
-
494
- # Compose the glyph for that spot
495
- if use_color:
496
- # Force black bg + white fg so it pops
497
- token = sgr(bg=BG_BLACK, fg=FG_WHITE) + ch + RESET
498
- else:
499
- # In text mode, just put the raw character
500
- token = ch
501
- tile[cy][cx] = token
502
-
503
- # Optional borders
504
- if borders:
505
- horiz = "─" * cell_w
506
- top = "┌" + "┬".join(horiz for _ in range(H)) + "┐"
507
- mid = "├" + "┼".join(horiz for _ in range(H)) + "┤"
508
- bot = "└" + "┴".join(horiz for _ in range(H)) + "┘"
509
-
510
- out_lines: List[str] = []
511
- if borders:
512
- out_lines.append(top)
513
-
514
- for r in range(V):
515
- # Build tiles for this row (so we can overlay per-cell text)
516
- row_tiles: List[List[List[str]]] = []
517
- for c in range(H):
518
- t = make_tile(grid[r][c])
519
- if cell_text is not None:
520
- label = cell_text(r, c)
521
- if label:
522
- overlay_center_char(t, grid[r][c], label)
523
- row_tiles.append(t)
524
-
525
- # Emit tile rows
526
- for y in range(cell_h):
527
- if borders:
528
- parts = ["│"]
529
- for c in range(H):
530
- parts.append("".join(row_tiles[c][y]))
531
- parts.append("│")
532
- out_lines.append("".join(parts))
533
- else:
534
- out_lines.append("".join("".join(row_tiles[c][y]) for c in range(H)))
535
-
536
- if borders and r < V - 1:
537
- out_lines.append(mid)
538
-
539
- if borders:
540
- out_lines.append(bot)
541
-
542
- return "\n".join(out_lines) + (RESET if use_color else "")
543
-
544
-
545
-
546
-
547
- # demo = [
548
- # ["TL","TR","BL","BR","B","W","BL","BR","B","W","TL","TR","BL","BR","B","W","BL","BR","B","W","W","BL","BR","B","W"],
549
- # ["W","BL","TR","BL","TL","BR","BL","BR","W","W","W","B","TR","BL","TL","BR","BL","BR","B","W","BR","BL","BR","B","W"],
550
- # ["BR","BL","TR","TL","W","B","BL","BR","B","W","BR","BL","TR","TL","W","B","BL","BR","B","W","B","BL","BR","B","W"],
551
- # ]
552
- # print(render_bw_tiles_split(demo, cell_w=8, cell_h=4, borders=True, mode="ansi"))
553
- # art = render_bw_tiles_split(
554
- # demo,
555
- # cell_w=8,
556
- # cell_h=4,
557
- # borders=True,
558
- # mode="text", # ← key change
559
- # text_palette="solid" # try "solid" for stark black/white
560
- # )
561
- # print("```text\n" + art + "\n```")
1
+ import numpy as np
2
+ from typing import Callable, Optional, List, Sequence, Literal
3
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_next_pos, in_bounds, set_char, get_char, Direction
4
+
5
+
6
+ def combined_function(V: int,
7
+ H: int,
8
+ cell_flags: Optional[Callable[[int, int], str]] = None,
9
+ is_shaded: Optional[Callable[[int, int], bool]] = None,
10
+ center_char: Optional[Callable[[int, int], str]] = None,
11
+ special_content: Optional[Callable[[int, int], str]] = None,
12
+ text_on_shaded_cells: bool = True,
13
+ scale_x: int = 2,
14
+ scale_y: int = 1,
15
+ show_axes: bool = True,
16
+ show_grid: bool = True,
17
+ ) -> str:
18
+ """
19
+ Render a V x H grid that can:
20
+ • draw selective edges per cell via cell_flags(r, c) containing any of 'U','D','L','R'
21
+ • shade cells via is_shaded(r, c)
22
+ • place centered text per cell via center_char(r, c)
23
+ • draw interior arms via special_content(r, c) returning any combo of 'U','D','L','R'
24
+ (e.g., 'UR', 'DL', 'ULRD', or '' to leave the interior unchanged).
25
+ Arms extend from the cell’s interior center toward the indicated sides.
26
+ • horizontal stretch (>=1). Interior width per cell = 2*scale_x - 1 (default 2)
27
+ • vertical stretch (>=1). Interior height per cell = scale_y (default 1)
28
+ • show_axes: bool = True, show the axes (columns on top, rows on the left).
29
+ • show_grid: bool = True, show the grid lines.
30
+
31
+ Behavior:
32
+ - If cell_flags is None, draws a full grid (all interior and outer borders present).
33
+ - Shading is applied first, borders are drawn on top, and center text is drawn last unless text_on_shaded_cells is False in which case the text is not drawn on shaded cells.
34
+ - Axes are shown (columns on top, rows on the left).
35
+
36
+ Draw order:
37
+ 1) shading
38
+ 2) borders
39
+ 3) special_content (interior line arms)
40
+ 4) center_char (unless text_on_shaded_cells=False and cell is shaded)
41
+ """
42
+ assert V >= 1 and H >= 1, f'V and H must be >= 1, got {V} and {H}'
43
+ assert cell_flags is None or callable(cell_flags), f'cell_flags must be None or callable, got {cell_flags}'
44
+ assert is_shaded is None or callable(is_shaded), f'is_shaded must be None or callable, got {is_shaded}'
45
+ assert center_char is None or callable(center_char), f'center_char must be None or callable, got {center_char}'
46
+ assert special_content is None or callable(special_content), f'special_content must be None or callable, got {special_content}'
47
+
48
+ # Rendering constants
49
+ fill_char: str = '▒' # single char for shaded interiors
50
+ empty_char: str = ' ' # single char for unshaded interiors
51
+
52
+ assert scale_x >= 1 and scale_y >= 1
53
+ assert len(fill_char) == 1 and len(empty_char) == 1
54
+
55
+ # ── Layout helpers ─────────────────────────────────────────────────────
56
+ def x_corner(c: int) -> int: # column of vertical border at grid column c (0..H)
57
+ return (2 * c) * scale_x
58
+ def y_border(r: int) -> int: # row of horizontal border at grid row r (0..V)
59
+ return (scale_y + 1) * r
60
+
61
+ rows = y_border(V) + 1
62
+ cols = x_corner(H) + 1
63
+ canvas = [[empty_char] * cols for _ in range(rows)]
64
+
65
+ # ── Edge presence arrays derived from cell_flags ──
66
+ # H_edges[r, c] is the horizontal edge between rows r and r+1 above column segment c (shape: (V+1, H))
67
+ # V_edges[r, c] is the vertical edge between cols c and c+1 left of row segment r (shape: (V, H+1))
68
+ if cell_flags is None:
69
+ # Full grid: all horizontal and vertical segments are present
70
+ H_edges = [[show_grid for _ in range(H)] for _ in range(V + 1)]
71
+ V_edges = [[show_grid for _ in range(H + 1)] for _ in range(V)]
72
+ else:
73
+ H_edges = [[False for _ in range(H)] for _ in range(V + 1)]
74
+ V_edges = [[False for _ in range(H + 1)] for _ in range(V)]
75
+ for r in range(V):
76
+ for c in range(H):
77
+ s = cell_flags(r, c) or ''
78
+ if 'U' in s:
79
+ H_edges[r ][c] = True
80
+ if 'D' in s:
81
+ H_edges[r + 1][c] = True
82
+ if 'L' in s:
83
+ V_edges[r][c ] = True
84
+ if 'R' in s:
85
+ V_edges[r][c + 1] = True
86
+
87
+ # ── Shading first (borders will overwrite) ─────────────────────────────
88
+ shaded_map = [[False]*H for _ in range(V)]
89
+ for r in range(V):
90
+ top = y_border(r) + 1
91
+ bottom = y_border(r + 1) - 1 # inclusive
92
+ if top > bottom:
93
+ continue
94
+ for c in range(H):
95
+ left = x_corner(c) + 1
96
+ right = x_corner(c + 1) - 1 # inclusive
97
+ if left > right:
98
+ continue
99
+ shaded = bool(is_shaded(r, c)) if callable(is_shaded) else False
100
+ shaded_map[r][c] = shaded
101
+ ch = fill_char if shaded else empty_char
102
+ for yy in range(top, bottom + 1):
103
+ for xx in range(left, right + 1):
104
+ canvas[yy][xx] = ch
105
+
106
+ # ── Grid lines (respect edge presence) ─────────────────────────────────
107
+ U, Rb, D, Lb = 1, 2, 4, 8
108
+ JUNCTION = {
109
+ 0: ' ',
110
+ U: '│', D: '│', U | D: '│',
111
+ Lb: '─', Rb: '─', Lb | Rb: '─',
112
+ U | Rb: '└', Rb | D: '┌', D | Lb: '┐', Lb | U: '┘',
113
+ U | D | Lb: '┤', U | D | Rb: '├', Lb | Rb | U: '┴', Lb | Rb | D: '┬',
114
+ U | Rb | D | Lb: '┼',
115
+ }
116
+
117
+ # Horizontal segments
118
+ for r in range(V + 1):
119
+ yy = y_border(r)
120
+ for c in range(H):
121
+ if H_edges[r][c]:
122
+ base = x_corner(c)
123
+ for k in range(1, 2 * scale_x): # 1..(2*scale_x-1)
124
+ canvas[yy][base + k] = '─'
125
+
126
+ # Vertical segments
127
+ for r in range(V):
128
+ for c in range(H + 1):
129
+ if V_edges[r][c]:
130
+ xx = x_corner(c)
131
+ for ky in range(1, scale_y + 1):
132
+ canvas[y_border(r) + ky][xx] = '│'
133
+
134
+ # Junctions at intersections
135
+ for r in range(V + 1):
136
+ yy = y_border(r)
137
+ for c in range(H + 1):
138
+ xx = x_corner(c)
139
+ m = 0
140
+ if r > 0 and V_edges[r - 1][c]:
141
+ m |= U
142
+ if r < V and V_edges[r][c]:
143
+ m |= D
144
+ if c > 0 and H_edges[r][c - 1]:
145
+ m |= Lb
146
+ if c < H and H_edges[r][c]:
147
+ m |= Rb
148
+ canvas[yy][xx] = JUNCTION[m]
149
+
150
+ # ── Special interior content (arms) + cross-cell bridges ──────────────
151
+ def draw_special_arms(r_cell: int, c_cell: int, code: Optional[str]):
152
+ if not code:
153
+ return
154
+ s = set(code)
155
+ # interior box
156
+ left = x_corner(c_cell) + 1
157
+ right = x_corner(c_cell + 1) - 1
158
+ top = y_border(r_cell) + 1
159
+ bottom= y_border(r_cell + 1) - 1
160
+ if left > right or top > bottom:
161
+ return
162
+
163
+ # center of interior
164
+ cx = left + (right - left) // 2
165
+ cy = top + (bottom - top) // 2
166
+
167
+ # draw arms out from center (keep inside interior; don't touch borders)
168
+ if 'U' in s and cy - 1 >= top:
169
+ for yy in range(cy - 1, top - 1, -1):
170
+ canvas[yy][cx] = '│'
171
+ if 'D' in s and cy + 1 <= bottom:
172
+ for yy in range(cy + 1, bottom + 1):
173
+ canvas[yy][cx] = '│'
174
+ if 'L' in s and cx - 1 >= left:
175
+ for xx in range(cx - 1, left - 1, -1):
176
+ canvas[cy][xx] = '─'
177
+ if 'R' in s and cx + 1 <= right:
178
+ for xx in range(cx + 1, right + 1):
179
+ canvas[cy][xx] = '─'
180
+ if '/' in s:
181
+ for xx in range(right - left + 1):
182
+ for yy in range(top - bottom + 1):
183
+ canvas[top + yy][left + xx] = '/'
184
+ if '\\' in s:
185
+ for xx in range(right - left + 1):
186
+ for yy in range(top - bottom + 1):
187
+ canvas[top + yy][left + xx] = '\\'
188
+
189
+ # center junction
190
+ U_b, R_b, D_b, L_b = 1, 2, 4, 8
191
+ m = 0
192
+ if 'U' in s:
193
+ m |= U_b
194
+ if 'D' in s:
195
+ m |= D_b
196
+ if 'L' in s:
197
+ m |= L_b
198
+ if 'R' in s:
199
+ m |= R_b
200
+ canvas[cy][cx] = JUNCTION.get(m, ' ')
201
+
202
+ # pass 1: draw interior arms per cell
203
+ special_map = [[set() for _ in range(H)] for _ in range(V)]
204
+ if callable(special_content):
205
+ for r in range(V):
206
+ for c in range(H):
207
+ flags = set(ch for ch in str(special_content(r, c) or ''))
208
+ special_map[r][c] = flags
209
+ if flags:
210
+ draw_special_arms(r, c, ''.join(flags))
211
+
212
+ # ── Center text (drawn last so it sits atop shading/arms) ─────────────
213
+ def put_center_text(r_cell: int, c_cell: int, s: Optional[str]):
214
+ if s is None:
215
+ return
216
+ s = str(s)
217
+ # interior box
218
+ left = x_corner(c_cell) + 1
219
+ right = x_corner(c_cell + 1) - 1
220
+ top = y_border(r_cell) + 1
221
+ bottom= y_border(r_cell + 1) - 1
222
+ if left > right or top > bottom:
223
+ return
224
+ span_w = right - left + 1
225
+ yy = top + (bottom - top) // 2
226
+ if len(s) > span_w:
227
+ s = s[:span_w] # truncate to protect borders
228
+ start = left + (span_w - len(s)) // 2
229
+ for i, ch in enumerate(s):
230
+ canvas[yy][start + i] = ch
231
+
232
+
233
+ # helper to get interior-center coordinates
234
+ def _cell_center_rc(r_cell: int, c_cell: int):
235
+ left = x_corner(c_cell) + 1
236
+ right = x_corner(c_cell + 1) - 1
237
+ top = y_border(r_cell) + 1
238
+ bottom= y_border(r_cell + 1) - 1
239
+ if left > right or top > bottom:
240
+ return None
241
+ cx = left + (right - left) // 2
242
+ cy = top + (bottom - top) // 2
243
+ return cy, cx
244
+
245
+ # ── REPLACE your place_connector() and "pass 2" with the following ────
246
+
247
+ # PASS 2: merge/bridge on every border using bitmasks (works with or without borders)
248
+ if callable(special_content):
249
+ # vertical borders: c in [0..H], between (r,c-1) and (r,c)
250
+ for r in range(V):
251
+ # y (row) where we draw the junction on this border: the interior center row
252
+ cc = _cell_center_rc(r, 0)
253
+ if cc is None:
254
+ continue
255
+ cy = cc[0]
256
+ for c in range(H + 1):
257
+ x = x_corner(c)
258
+ mask = 0
259
+ # base: if the vertical grid line exists here, add U and D
260
+ if V_edges[r][c]:
261
+ mask |= U | D
262
+
263
+ # neighbors pointing toward this vertical border
264
+ left_flags = special_map[r][c - 1] if c - 1 >= 0 else set()
265
+ right_flags = special_map[r][c] if c < H else set()
266
+ if 'R' in left_flags:
267
+ mask |= Lb
268
+ if 'L' in right_flags:
269
+ mask |= Rb
270
+
271
+ # nothing to draw? leave whatever is already there
272
+ if mask == 0:
273
+ continue
274
+ canvas[cy][x] = JUNCTION[mask]
275
+
276
+ # horizontal borders: r in [0..V], between (r-1,c) and (r,c)
277
+ for c in range(H):
278
+ # x (col) where we draw the junction on this border: the interior center col
279
+ cc = _cell_center_rc(0, c)
280
+ if cc is None:
281
+ continue
282
+ cx = cc[1]
283
+ for r in range(V + 1):
284
+ y = y_border(r)
285
+ mask = 0
286
+ # base: if the horizontal grid line exists here, add L and R
287
+ if r <= V - 1 and H_edges[r][c]: # H_edges indexed [0..V] x [0..H-1]
288
+ mask |= Lb | Rb
289
+
290
+ # neighbors pointing toward this horizontal border
291
+ up_flags = special_map[r - 1][c] if r - 1 >= 0 else set()
292
+ down_flags = special_map[r][c] if r < V else set()
293
+ if 'D' in up_flags:
294
+ mask |= U
295
+ if 'U' in down_flags:
296
+ mask |= D
297
+
298
+ if mask == 0:
299
+ continue
300
+ canvas[y][cx] = JUNCTION[mask]
301
+
302
+ if callable(center_char):
303
+ for r in range(V):
304
+ for c in range(H):
305
+ if not text_on_shaded_cells and shaded_map[r][c]:
306
+ continue
307
+ if not text_on_shaded_cells and special_map[r][c]:
308
+ continue
309
+ put_center_text(r, c, center_char(r, c))
310
+
311
+ # ── Stringify with axes ────────────────────────────────────────────────
312
+ art_rows = [''.join(row) for row in canvas]
313
+ if not show_axes:
314
+ return '\n'.join(art_rows)
315
+
316
+ # Axes labels: columns on top; rows on left
317
+ gut = max(2, len(str(V - 1)))
318
+ gutter = ' ' * gut
319
+ top_tens = list(gutter + ' ' * cols)
320
+ top_ones = list(gutter + ' ' * cols)
321
+ for c in range(H):
322
+ xc_center = x_corner(c) + scale_x
323
+ if H >= 10:
324
+ top_tens[gut + xc_center] = str((c // 10) % 10)
325
+ top_ones[gut + xc_center] = str(c % 10)
326
+ if gut >= 2:
327
+ top_tens[gut - 2:gut] = list(' ')
328
+ top_ones[gut - 2:gut] = list(' ')
329
+
330
+ labeled = []
331
+ for y, line in enumerate(art_rows):
332
+ mod = y % (scale_y + 1)
333
+ if 1 <= mod <= scale_y:
334
+ r = y // (scale_y + 1)
335
+ mid = (scale_y + 1) // 2
336
+ label = (str(r).rjust(gut) if mod == mid else ' ' * gut)
337
+ else:
338
+ label = ' ' * gut
339
+ labeled.append(label + line)
340
+
341
+ return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
342
+
343
+ def id_board_to_wall_fn(id_board: np.array, border_is_wall = True) -> Callable[[int, int], str]:
344
+ """In many instances, we have a 2d array where cell values are arbitrary ids
345
+ and we want to convert it to a 2d array where cell values are walls "U", "D", "L", "R" to represent the edges that separate me from my neighbors that have different ids.
346
+ Args:
347
+ id_board: np.array of shape (N, N) with arbitrary ids.
348
+ border_is_wall: if True, the edges of the board are considered to be walls.
349
+ Returns:
350
+ Callable[[int, int], str] that returns the walls "U", "D", "L", "R" for the cell at (r, c).
351
+ """
352
+ res = np.full((id_board.shape[0], id_board.shape[1]), '', dtype=object)
353
+ V, H = id_board.shape
354
+ def append_char(pos: Pos, s: str):
355
+ set_char(res, pos, get_char(res, pos) + s)
356
+ def handle_pos_direction(pos: Pos, direction: Direction, s: str):
357
+ pos2 = get_next_pos(pos, direction)
358
+ if in_bounds(pos2, V, H):
359
+ if get_char(id_board, pos2) != get_char(id_board, pos):
360
+ append_char(pos, s)
361
+ else:
362
+ if border_is_wall:
363
+ append_char(pos, s)
364
+ for pos in get_all_pos(V, H):
365
+ handle_pos_direction(pos, Direction.LEFT, 'L')
366
+ handle_pos_direction(pos, Direction.RIGHT, 'R')
367
+ handle_pos_direction(pos, Direction.UP, 'U')
368
+ handle_pos_direction(pos, Direction.DOWN, 'D')
369
+ return lambda r, c: res[r][c]
370
+
371
+ CellVal = Literal["B", "W", "TL", "TR", "BL", "BR"]
372
+ GridLike = Sequence[Sequence[CellVal]]
373
+
374
+ def render_bw_tiles_split(
375
+ grid: GridLike,
376
+ cell_w: int = 6,
377
+ cell_h: int = 3,
378
+ borders: bool = False,
379
+ mode: Literal["ansi", "text"] = "ansi",
380
+ text_palette: Literal["solid", "hatch"] = "solid",
381
+ cell_text: Optional[Callable[[int, int], str]] = None) -> str:
382
+ """
383
+ Render a VxH grid with '/' or '\\' splits and optional per-cell centered text.
384
+
385
+ `cell_text(r, c) -> str`: if returns non-empty, its first character is drawn
386
+ near the geometric center of cell (r,c), nudged to the black side for halves.
387
+ """
388
+
389
+ V = len(grid)
390
+ if V == 0:
391
+ return ""
392
+ H = len(grid[0])
393
+ if any(len(row) != H for row in grid):
394
+ raise ValueError("All rows must have the same length")
395
+ if cell_w < 1 or cell_h < 1:
396
+ raise ValueError("cell_w and cell_h must be >= 1")
397
+
398
+ allowed = {"B","W","TL","TR","BL","BR"}
399
+ for r in range(V):
400
+ for c in range(H):
401
+ if grid[r][c] not in allowed:
402
+ raise ValueError(f"Invalid cell value at ({r},{c}): {grid[r][c]}")
403
+
404
+ # ── Mode setup ─────────────────────────────────────────────────────────
405
+ use_color = (mode == "ansi")
406
+
407
+ def sgr(bg: int | None = None, fg: int | None = None) -> str:
408
+ if not use_color:
409
+ return ""
410
+ parts = []
411
+ if fg is not None:
412
+ parts.append(str(fg))
413
+ if bg is not None:
414
+ parts.append(str(bg))
415
+ return ("\x1b[" + ";".join(parts) + "m") if parts else ""
416
+
417
+ RESET = "\x1b[0m" if use_color else ""
418
+
419
+ BG_BLACK, BG_WHITE = 40, 47
420
+ FG_BLACK, FG_WHITE = 30, 37
421
+
422
+ if text_palette == "solid":
423
+ TXT_BLACK, TXT_WHITE = " ", "█"
424
+ elif text_palette == "hatch":
425
+ TXT_BLACK, TXT_WHITE = "░", "▓"
426
+ else:
427
+ raise ValueError("text_palette must be 'solid' or 'hatch'")
428
+
429
+ def diag_kind_and_slash(val: CellVal):
430
+ if val in ("TR", "BL"):
431
+ return "main", "\\"
432
+ elif val in ("TL", "BR"):
433
+ return "anti", "/"
434
+ return None, "?"
435
+
436
+ def is_black(val: CellVal, fx: float, fy: float) -> bool:
437
+ if val == "B":
438
+ return True
439
+ if val == "W":
440
+ return False
441
+ kind, _ = diag_kind_and_slash(val)
442
+ if kind == "main": # y = x
443
+ return (fy < fx) if val == "TR" else (fy > fx)
444
+ else: # y = 1 - x
445
+ return (fy < 1 - fx) if val == "TL" else (fy > 1 - fx)
446
+
447
+ # Build one tile as a matrix of 1-char tokens (already colorized if ANSI)
448
+ def make_tile(val: CellVal) -> List[List[str]]:
449
+ rows: List[List[str]] = []
450
+ _, slash_ch = diag_kind_and_slash(val)
451
+ for y in range(cell_h):
452
+ fy = (y + 0.5) / cell_h
453
+ line: List[str] = []
454
+ prev = None
455
+ for x in range(cell_w):
456
+ fx = (x + 0.5) / cell_w
457
+ fx_next = (x + 1.5) / cell_w
458
+
459
+ if val == "B":
460
+ line.append(sgr(bg=BG_BLACK) + " " + RESET if use_color else TXT_BLACK)
461
+ continue
462
+ if val == "W":
463
+ line.append(sgr(bg=BG_WHITE) + " " + RESET if use_color else TXT_WHITE)
464
+ continue
465
+
466
+ black_side = is_black(val, fx, fy)
467
+ next_black_side = is_black(val, fx_next, fy)
468
+ boundary = False # if true places a "/" or "\" at the current position
469
+ if prev is not None and not prev and black_side: # prev white and cur black => boundary now
470
+ boundary = True
471
+ if black_side and not next_black_side: # cur black and next white => boundary now
472
+ boundary = True
473
+
474
+ if use_color:
475
+ bg = BG_BLACK if black_side else BG_WHITE
476
+ if boundary:
477
+ fg = FG_WHITE if bg == BG_BLACK else FG_BLACK
478
+ line.append(sgr(bg=bg, fg=fg) + slash_ch + RESET)
479
+ else:
480
+ line.append(sgr(bg=bg) + " " + RESET)
481
+ else:
482
+ if boundary:
483
+ line.append(slash_ch)
484
+ else:
485
+ line.append(TXT_BLACK if black_side else TXT_WHITE)
486
+ prev = black_side
487
+ rows.append(line)
488
+ return rows
489
+
490
+ # Overlay a single character centered (nudged into black side if needed)
491
+ def overlay_center_char(tile: List[List[str]], val: CellVal, ch: str):
492
+ if not ch:
493
+ return
494
+ ch = ch[0] # keep one character (user said single number)
495
+ cx, cy = cell_w // 2, cell_h // 2
496
+ cx -= 1
497
+
498
+ # Compose the glyph for that spot
499
+ if use_color:
500
+ # Force black bg + white fg so it pops
501
+ token = sgr(bg=BG_BLACK, fg=FG_WHITE) + ch + RESET
502
+ else:
503
+ # In text mode, just put the raw character
504
+ token = ch
505
+ tile[cy][cx] = token
506
+
507
+ # Optional borders
508
+ if borders:
509
+ horiz = "─" * cell_w
510
+ top = "┌" + "┬".join(horiz for _ in range(H)) + "┐"
511
+ mid = "├" + "┼".join(horiz for _ in range(H)) + "┤"
512
+ bot = "└" + "┴".join(horiz for _ in range(H)) + "┘"
513
+
514
+ out_lines: List[str] = []
515
+ if borders:
516
+ out_lines.append(top)
517
+
518
+ for r in range(V):
519
+ # Build tiles for this row (so we can overlay per-cell text)
520
+ row_tiles: List[List[List[str]]] = []
521
+ for c in range(H):
522
+ t = make_tile(grid[r][c])
523
+ if cell_text is not None:
524
+ label = cell_text(r, c)
525
+ if label:
526
+ overlay_center_char(t, grid[r][c], label)
527
+ row_tiles.append(t)
528
+
529
+ # Emit tile rows
530
+ for y in range(cell_h):
531
+ if borders:
532
+ parts = [""]
533
+ for c in range(H):
534
+ parts.append("".join(row_tiles[c][y]))
535
+ parts.append("│")
536
+ out_lines.append("".join(parts))
537
+ else:
538
+ out_lines.append("".join("".join(row_tiles[c][y]) for c in range(H)))
539
+
540
+ if borders and r < V - 1:
541
+ out_lines.append(mid)
542
+
543
+ if borders:
544
+ out_lines.append(bot)
545
+
546
+ return "\n".join(out_lines) + (RESET if use_color else "")
547
+
548
+
549
+
550
+
551
+ # demo = [
552
+ # ["TL","TR","BL","BR","B","W","BL","BR","B","W","TL","TR","BL","BR","B","W","BL","BR","B","W","W","BL","BR","B","W"],
553
+ # ["W","BL","TR","BL","TL","BR","BL","BR","W","W","W","B","TR","BL","TL","BR","BL","BR","B","W","BR","BL","BR","B","W"],
554
+ # ["BR","BL","TR","TL","W","B","BL","BR","B","W","BR","BL","TR","TL","W","B","BL","BR","B","W","B","BL","BR","B","W"],
555
+ # ]
556
+ # print(render_bw_tiles_split(demo, cell_w=8, cell_h=4, borders=True, mode="ansi"))
557
+ # art = render_bw_tiles_split(
558
+ # demo,
559
+ # cell_w=8,
560
+ # cell_h=4,
561
+ # borders=True,
562
+ # mode="text", # ← key change
563
+ # text_palette="solid" # try "solid" for stark black/white
564
+ # )
565
+ # print("```text\n" + art + "\n```")