retroflow 0.8.2__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.
- retroflow/__init__.py +48 -0
- retroflow/generator.py +1803 -0
- retroflow/layout.py +239 -0
- retroflow/parser.py +99 -0
- retroflow/py.typed +0 -0
- retroflow/renderer.py +486 -0
- retroflow/router.py +343 -0
- retroflow-0.8.2.dist-info/METADATA +445 -0
- retroflow-0.8.2.dist-info/RECORD +11 -0
- retroflow-0.8.2.dist-info/WHEEL +4 -0
- retroflow-0.8.2.dist-info/licenses/LICENSE +21 -0
retroflow/renderer.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASCII renderer module for flowchart generation.
|
|
3
|
+
|
|
4
|
+
Handles drawing boxes with shadows, text wrapping, and line art
|
|
5
|
+
using Unicode box-drawing characters.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
# Unicode box-drawing characters
|
|
12
|
+
BOX_CHARS = {
|
|
13
|
+
"top_left": "┌",
|
|
14
|
+
"top_right": "┐",
|
|
15
|
+
"bottom_left": "└",
|
|
16
|
+
"bottom_right": "┘",
|
|
17
|
+
"horizontal": "─",
|
|
18
|
+
"vertical": "│",
|
|
19
|
+
"shadow": "░",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Double-line box characters (for titles/headers)
|
|
23
|
+
BOX_CHARS_DOUBLE = {
|
|
24
|
+
"top_left": "╔",
|
|
25
|
+
"top_right": "╗",
|
|
26
|
+
"bottom_left": "╚",
|
|
27
|
+
"bottom_right": "╝",
|
|
28
|
+
"horizontal": "═",
|
|
29
|
+
"vertical": "║",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Rounded corner variants
|
|
33
|
+
BOX_CHARS_ROUNDED = {
|
|
34
|
+
"top_left": "╭",
|
|
35
|
+
"top_right": "╮",
|
|
36
|
+
"bottom_left": "╰",
|
|
37
|
+
"bottom_right": "╯",
|
|
38
|
+
"horizontal": "─",
|
|
39
|
+
"vertical": "│",
|
|
40
|
+
"shadow": "░",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Arrow characters
|
|
44
|
+
ARROW_CHARS = {
|
|
45
|
+
"down": "▼",
|
|
46
|
+
"up": "▲",
|
|
47
|
+
"right": "►",
|
|
48
|
+
"left": "◄",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Line drawing characters for routing
|
|
52
|
+
LINE_CHARS = {
|
|
53
|
+
"horizontal": "─",
|
|
54
|
+
"vertical": "│",
|
|
55
|
+
"corner_top_left": "┌",
|
|
56
|
+
"corner_top_right": "┐",
|
|
57
|
+
"corner_bottom_left": "└",
|
|
58
|
+
"corner_bottom_right": "┘",
|
|
59
|
+
"tee_right": "├",
|
|
60
|
+
"tee_left": "┤",
|
|
61
|
+
"tee_down": "┬",
|
|
62
|
+
"tee_up": "┴",
|
|
63
|
+
"cross": "┼",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class BoxDimensions:
|
|
69
|
+
"""Dimensions of a rendered box."""
|
|
70
|
+
|
|
71
|
+
width: int # Total width including border
|
|
72
|
+
height: int # Total height including border
|
|
73
|
+
text_lines: List[str] # Wrapped text lines
|
|
74
|
+
padding: int = 1 # Internal padding
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Canvas:
|
|
78
|
+
"""
|
|
79
|
+
A 2D character canvas for drawing ASCII art.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, width: int, height: int, fill_char: str = " "):
|
|
83
|
+
self.width = width
|
|
84
|
+
self.height = height
|
|
85
|
+
self.grid: List[List[str]] = [
|
|
86
|
+
[fill_char for _ in range(width)] for _ in range(height)
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
def set(self, x: int, y: int, char: str) -> None:
|
|
90
|
+
"""Set a character at position (x, y)."""
|
|
91
|
+
if 0 <= x < self.width and 0 <= y < self.height:
|
|
92
|
+
self.grid[y][x] = char
|
|
93
|
+
|
|
94
|
+
def get(self, x: int, y: int) -> str:
|
|
95
|
+
"""Get character at position (x, y)."""
|
|
96
|
+
if 0 <= x < self.width and 0 <= y < self.height:
|
|
97
|
+
return self.grid[y][x]
|
|
98
|
+
return " "
|
|
99
|
+
|
|
100
|
+
def draw_text(self, x: int, y: int, text: str) -> None:
|
|
101
|
+
"""Draw text starting at position (x, y)."""
|
|
102
|
+
for i, char in enumerate(text):
|
|
103
|
+
self.set(x + i, y, char)
|
|
104
|
+
|
|
105
|
+
def render(self) -> str:
|
|
106
|
+
"""Render the canvas to a string."""
|
|
107
|
+
lines = []
|
|
108
|
+
for row in self.grid:
|
|
109
|
+
line = "".join(row).rstrip()
|
|
110
|
+
lines.append(line)
|
|
111
|
+
|
|
112
|
+
# Remove trailing empty lines
|
|
113
|
+
while lines and not lines[-1]:
|
|
114
|
+
lines.pop()
|
|
115
|
+
|
|
116
|
+
return "\n".join(lines)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class BoxRenderer:
|
|
120
|
+
"""
|
|
121
|
+
Renders boxes with shadows and wrapped text.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
max_text_width: int = 20,
|
|
127
|
+
padding: int = 2,
|
|
128
|
+
shadow: bool = True,
|
|
129
|
+
rounded: bool = False,
|
|
130
|
+
compact: bool = False,
|
|
131
|
+
):
|
|
132
|
+
self.max_text_width = max_text_width
|
|
133
|
+
self.padding = padding
|
|
134
|
+
self.shadow = shadow
|
|
135
|
+
self.rounded = rounded
|
|
136
|
+
self.compact = compact
|
|
137
|
+
self.box_chars = BOX_CHARS_ROUNDED if rounded else BOX_CHARS
|
|
138
|
+
|
|
139
|
+
def calculate_box_dimensions(self, text: str) -> BoxDimensions:
|
|
140
|
+
"""
|
|
141
|
+
Calculate box dimensions based on text content.
|
|
142
|
+
Text is wrapped to fit within max_text_width.
|
|
143
|
+
"""
|
|
144
|
+
words = text.split()
|
|
145
|
+
lines: List[str] = []
|
|
146
|
+
current_line: List[str] = []
|
|
147
|
+
current_length = 0
|
|
148
|
+
|
|
149
|
+
for word in words:
|
|
150
|
+
word_len = len(word)
|
|
151
|
+
space_needed = 1 if current_line else 0
|
|
152
|
+
|
|
153
|
+
if current_length + word_len + space_needed <= self.max_text_width:
|
|
154
|
+
current_line.append(word)
|
|
155
|
+
current_length += word_len + (1 if len(current_line) > 1 else 0)
|
|
156
|
+
else:
|
|
157
|
+
if current_line:
|
|
158
|
+
lines.append(" ".join(current_line))
|
|
159
|
+
current_line = [word]
|
|
160
|
+
current_length = word_len
|
|
161
|
+
|
|
162
|
+
if current_line:
|
|
163
|
+
lines.append(" ".join(current_line))
|
|
164
|
+
|
|
165
|
+
if not lines:
|
|
166
|
+
lines = [""]
|
|
167
|
+
|
|
168
|
+
# Calculate dimensions
|
|
169
|
+
max_line_width = max(len(line) for line in lines)
|
|
170
|
+
text_width = max(max_line_width, 1)
|
|
171
|
+
|
|
172
|
+
# Box width = text_width + 2*padding + 2 (for borders)
|
|
173
|
+
box_width = text_width + 2 * self.padding + 2
|
|
174
|
+
|
|
175
|
+
# Box height = num_lines + 2 (for borders) + vertical padding
|
|
176
|
+
# Compact mode: no vertical padding (height = lines + 2)
|
|
177
|
+
# Normal mode: 1 line padding top and bottom (height = lines + 4)
|
|
178
|
+
if self.compact:
|
|
179
|
+
box_height = len(lines) + 2
|
|
180
|
+
else:
|
|
181
|
+
box_height = len(lines) + 4
|
|
182
|
+
|
|
183
|
+
return BoxDimensions(
|
|
184
|
+
width=box_width, height=box_height, text_lines=lines, padding=self.padding
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def draw_box(
|
|
188
|
+
self, canvas: Canvas, x: int, y: int, dimensions: BoxDimensions
|
|
189
|
+
) -> None:
|
|
190
|
+
"""
|
|
191
|
+
Draw a box with shadow at position (x, y).
|
|
192
|
+
|
|
193
|
+
Box structure (shadow on right side of content and bottom):
|
|
194
|
+
┌───────────┐
|
|
195
|
+
│ TEXT │░
|
|
196
|
+
└───────────┘░
|
|
197
|
+
░░░░░░░░░░░░
|
|
198
|
+
"""
|
|
199
|
+
w = dimensions.width
|
|
200
|
+
h = dimensions.height
|
|
201
|
+
chars = self.box_chars
|
|
202
|
+
|
|
203
|
+
# Draw top border (no shadow on top row)
|
|
204
|
+
canvas.set(x, y, chars["top_left"])
|
|
205
|
+
for i in range(1, w - 1):
|
|
206
|
+
canvas.set(x + i, y, chars["horizontal"])
|
|
207
|
+
canvas.set(x + w - 1, y, chars["top_right"])
|
|
208
|
+
|
|
209
|
+
# Draw sides and content
|
|
210
|
+
for row in range(1, h - 1):
|
|
211
|
+
canvas.set(x, y + row, chars["vertical"])
|
|
212
|
+
canvas.set(x + w - 1, y + row, chars["vertical"])
|
|
213
|
+
|
|
214
|
+
# Draw shadow on right side (content rows only)
|
|
215
|
+
if self.shadow:
|
|
216
|
+
canvas.set(x + w, y + row, chars["shadow"])
|
|
217
|
+
|
|
218
|
+
# Draw bottom border
|
|
219
|
+
canvas.set(x, y + h - 1, chars["bottom_left"])
|
|
220
|
+
for i in range(1, w - 1):
|
|
221
|
+
canvas.set(x + i, y + h - 1, chars["horizontal"])
|
|
222
|
+
canvas.set(x + w - 1, y + h - 1, chars["bottom_right"])
|
|
223
|
+
|
|
224
|
+
# Draw shadow on right side of bottom border
|
|
225
|
+
if self.shadow:
|
|
226
|
+
canvas.set(x + w, y + h - 1, chars["shadow"])
|
|
227
|
+
|
|
228
|
+
# Draw bottom shadow (offset by 1 to align under content, not under left border)
|
|
229
|
+
if self.shadow:
|
|
230
|
+
for i in range(1, w + 1):
|
|
231
|
+
canvas.set(x + i, y + h, chars["shadow"])
|
|
232
|
+
|
|
233
|
+
# Draw text (centered)
|
|
234
|
+
# Compact mode: text starts at row 1 (right after top border)
|
|
235
|
+
# Normal mode: text starts at row 2 (1 line vertical padding)
|
|
236
|
+
text_start_y = y + 1 if self.compact else y + 2
|
|
237
|
+
for line_idx, line in enumerate(dimensions.text_lines):
|
|
238
|
+
text_y = text_start_y + line_idx
|
|
239
|
+
# Center the text within the box
|
|
240
|
+
available_width = w - 2 # Minus borders
|
|
241
|
+
text_x = x + 1 + (available_width - len(line)) // 2
|
|
242
|
+
canvas.draw_text(text_x, text_y, line)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class LineRenderer:
|
|
246
|
+
"""
|
|
247
|
+
Renders lines and arrows between boxes.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def draw_vertical_line(
|
|
251
|
+
self,
|
|
252
|
+
canvas: Canvas,
|
|
253
|
+
x: int,
|
|
254
|
+
y_start: int,
|
|
255
|
+
y_end: int,
|
|
256
|
+
arrow_at_end: bool = True,
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Draw a vertical line from y_start to y_end."""
|
|
259
|
+
if y_start > y_end:
|
|
260
|
+
y_start, y_end = y_end, y_start
|
|
261
|
+
direction = "up"
|
|
262
|
+
else:
|
|
263
|
+
direction = "down"
|
|
264
|
+
|
|
265
|
+
for y in range(y_start, y_end):
|
|
266
|
+
current = canvas.get(x, y)
|
|
267
|
+
if current == LINE_CHARS["horizontal"]:
|
|
268
|
+
canvas.set(x, y, LINE_CHARS["cross"])
|
|
269
|
+
elif current == LINE_CHARS["corner_top_left"]:
|
|
270
|
+
canvas.set(x, y, LINE_CHARS["tee_right"])
|
|
271
|
+
elif current == LINE_CHARS["corner_top_right"]:
|
|
272
|
+
canvas.set(x, y, LINE_CHARS["tee_left"])
|
|
273
|
+
elif current == LINE_CHARS["corner_bottom_left"]:
|
|
274
|
+
canvas.set(x, y, LINE_CHARS["tee_right"])
|
|
275
|
+
elif current == LINE_CHARS["corner_bottom_right"]:
|
|
276
|
+
canvas.set(x, y, LINE_CHARS["tee_left"])
|
|
277
|
+
elif current in (ARROW_CHARS["down"], ARROW_CHARS["up"]):
|
|
278
|
+
pass # Don't overwrite arrows
|
|
279
|
+
elif current in (" ", LINE_CHARS["vertical"], BOX_CHARS["shadow"]):
|
|
280
|
+
canvas.set(x, y, LINE_CHARS["vertical"])
|
|
281
|
+
|
|
282
|
+
# Draw arrow at end
|
|
283
|
+
if arrow_at_end:
|
|
284
|
+
arrow_y = y_end if direction == "down" else y_start
|
|
285
|
+
canvas.set(x, arrow_y, ARROW_CHARS[direction])
|
|
286
|
+
|
|
287
|
+
def draw_horizontal_line(
|
|
288
|
+
self,
|
|
289
|
+
canvas: Canvas,
|
|
290
|
+
x_start: int,
|
|
291
|
+
x_end: int,
|
|
292
|
+
y: int,
|
|
293
|
+
arrow_at_end: bool = True,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Draw a horizontal line from x_start to x_end."""
|
|
296
|
+
if x_start > x_end:
|
|
297
|
+
x_start, x_end = x_end, x_start
|
|
298
|
+
direction = "left"
|
|
299
|
+
else:
|
|
300
|
+
direction = "right"
|
|
301
|
+
|
|
302
|
+
for x in range(x_start, x_end):
|
|
303
|
+
current = canvas.get(x, y)
|
|
304
|
+
if current == LINE_CHARS["vertical"]:
|
|
305
|
+
canvas.set(x, y, LINE_CHARS["cross"])
|
|
306
|
+
elif current == LINE_CHARS["corner_top_left"]:
|
|
307
|
+
canvas.set(x, y, LINE_CHARS["tee_down"])
|
|
308
|
+
elif current == LINE_CHARS["corner_top_right"]:
|
|
309
|
+
canvas.set(x, y, LINE_CHARS["tee_down"])
|
|
310
|
+
elif current == LINE_CHARS["corner_bottom_left"]:
|
|
311
|
+
canvas.set(x, y, LINE_CHARS["tee_up"])
|
|
312
|
+
elif current == LINE_CHARS["corner_bottom_right"]:
|
|
313
|
+
canvas.set(x, y, LINE_CHARS["tee_up"])
|
|
314
|
+
elif current in (ARROW_CHARS["left"], ARROW_CHARS["right"]):
|
|
315
|
+
pass # Don't overwrite arrows
|
|
316
|
+
elif current in (" ", LINE_CHARS["horizontal"], BOX_CHARS["shadow"]):
|
|
317
|
+
canvas.set(x, y, LINE_CHARS["horizontal"])
|
|
318
|
+
|
|
319
|
+
# Draw arrow at end
|
|
320
|
+
if arrow_at_end:
|
|
321
|
+
arrow_x = x_end if direction == "right" else x_start
|
|
322
|
+
canvas.set(arrow_x, y, ARROW_CHARS[direction])
|
|
323
|
+
|
|
324
|
+
def draw_corner(self, canvas: Canvas, x: int, y: int, corner_type: str) -> None:
|
|
325
|
+
"""
|
|
326
|
+
Draw a corner character.
|
|
327
|
+
corner_type: 'top_left', 'top_right', 'bottom_left', 'bottom_right'
|
|
328
|
+
"""
|
|
329
|
+
current = canvas.get(x, y)
|
|
330
|
+
|
|
331
|
+
if current in (" ", BOX_CHARS["shadow"]):
|
|
332
|
+
canvas.set(x, y, LINE_CHARS[f"corner_{corner_type}"])
|
|
333
|
+
elif current == LINE_CHARS["horizontal"]:
|
|
334
|
+
if "top" in corner_type:
|
|
335
|
+
canvas.set(x, y, LINE_CHARS["tee_down"])
|
|
336
|
+
else:
|
|
337
|
+
canvas.set(x, y, LINE_CHARS["tee_up"])
|
|
338
|
+
elif current == LINE_CHARS["vertical"]:
|
|
339
|
+
if "left" in corner_type:
|
|
340
|
+
canvas.set(x, y, LINE_CHARS["tee_right"])
|
|
341
|
+
else:
|
|
342
|
+
canvas.set(x, y, LINE_CHARS["tee_left"])
|
|
343
|
+
elif current.startswith("corner_") or current in LINE_CHARS.values():
|
|
344
|
+
canvas.set(x, y, LINE_CHARS["cross"])
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class TitleRenderer:
|
|
348
|
+
"""
|
|
349
|
+
Renders title banners with double-line borders.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
def __init__(self, padding: int = 2, max_line_width: int = 15):
|
|
353
|
+
"""
|
|
354
|
+
Initialize the title renderer.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
padding: Horizontal padding inside the title box
|
|
358
|
+
max_line_width: Maximum width for text before wrapping (default 15)
|
|
359
|
+
"""
|
|
360
|
+
self.padding = padding
|
|
361
|
+
self.max_line_width = max_line_width
|
|
362
|
+
self.box_chars = BOX_CHARS_DOUBLE
|
|
363
|
+
|
|
364
|
+
def _wrap_title_text(self, title: str) -> List[str]:
|
|
365
|
+
"""
|
|
366
|
+
Wrap title text at word boundaries, respecting max_line_width.
|
|
367
|
+
|
|
368
|
+
Words are wrapped so that each line doesn't exceed max_line_width
|
|
369
|
+
characters (wrapping at the word boundary after reaching the limit).
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
title: The title text to wrap
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
List of wrapped lines
|
|
376
|
+
"""
|
|
377
|
+
words = title.split()
|
|
378
|
+
if not words:
|
|
379
|
+
return [""]
|
|
380
|
+
|
|
381
|
+
lines: List[str] = []
|
|
382
|
+
current_line: List[str] = []
|
|
383
|
+
current_length = 0
|
|
384
|
+
|
|
385
|
+
for word in words:
|
|
386
|
+
word_len = len(word)
|
|
387
|
+
space_needed = 1 if current_line else 0
|
|
388
|
+
|
|
389
|
+
# Check if adding this word exceeds the limit
|
|
390
|
+
if current_length + space_needed + word_len > self.max_line_width:
|
|
391
|
+
# If we have content on the current line, save it and start new line
|
|
392
|
+
if current_line:
|
|
393
|
+
lines.append(" ".join(current_line))
|
|
394
|
+
current_line = [word]
|
|
395
|
+
current_length = word_len
|
|
396
|
+
else:
|
|
397
|
+
# Single word exceeds limit - just add it anyway
|
|
398
|
+
lines.append(word)
|
|
399
|
+
current_line = []
|
|
400
|
+
current_length = 0
|
|
401
|
+
else:
|
|
402
|
+
current_line.append(word)
|
|
403
|
+
current_length += space_needed + word_len
|
|
404
|
+
|
|
405
|
+
# Add remaining content
|
|
406
|
+
if current_line:
|
|
407
|
+
lines.append(" ".join(current_line))
|
|
408
|
+
|
|
409
|
+
return lines if lines else [""]
|
|
410
|
+
|
|
411
|
+
def calculate_title_dimensions(self, title: str, min_width: int = 0) -> tuple:
|
|
412
|
+
"""
|
|
413
|
+
Calculate the dimensions needed for a title banner.
|
|
414
|
+
|
|
415
|
+
The title is wrapped at word boundaries respecting max_line_width,
|
|
416
|
+
and the box is sized to fit the wrapped text (not the diagram width).
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
title: The title text
|
|
420
|
+
min_width: Minimum width for the title box (ignored for sizing,
|
|
421
|
+
but returned for compatibility)
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Tuple of (width, height) for the title box
|
|
425
|
+
"""
|
|
426
|
+
# Wrap the title text
|
|
427
|
+
lines = self._wrap_title_text(title)
|
|
428
|
+
|
|
429
|
+
# Calculate width based on longest wrapped line
|
|
430
|
+
max_line_len = max(len(line) for line in lines)
|
|
431
|
+
|
|
432
|
+
# Title box: border + padding + text + padding + border
|
|
433
|
+
box_width = max_line_len + 2 * self.padding + 2
|
|
434
|
+
# Height: top border + text lines + bottom border
|
|
435
|
+
box_height = len(lines) + 2
|
|
436
|
+
|
|
437
|
+
return box_width, box_height
|
|
438
|
+
|
|
439
|
+
def draw_title(self, canvas: Canvas, x: int, y: int, title: str, width: int) -> int:
|
|
440
|
+
"""
|
|
441
|
+
Draw a title banner with double-line border.
|
|
442
|
+
|
|
443
|
+
The title is wrapped at word boundaries and centered within the box.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
canvas: The canvas to draw on
|
|
447
|
+
x: X position (left edge)
|
|
448
|
+
y: Y position (top edge)
|
|
449
|
+
title: The title text
|
|
450
|
+
width: Total width of the title box (used for centering text)
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
The height of the title box (for positioning content below)
|
|
454
|
+
"""
|
|
455
|
+
chars = self.box_chars
|
|
456
|
+
lines = self._wrap_title_text(title)
|
|
457
|
+
|
|
458
|
+
# Recalculate actual width based on content
|
|
459
|
+
max_line_len = max(len(line) for line in lines)
|
|
460
|
+
actual_width = max_line_len + 2 * self.padding + 2
|
|
461
|
+
height = len(lines) + 2
|
|
462
|
+
|
|
463
|
+
# Draw top border
|
|
464
|
+
canvas.set(x, y, chars["top_left"])
|
|
465
|
+
for i in range(1, actual_width - 1):
|
|
466
|
+
canvas.set(x + i, y, chars["horizontal"])
|
|
467
|
+
canvas.set(x + actual_width - 1, y, chars["top_right"])
|
|
468
|
+
|
|
469
|
+
# Draw middle rows with title text (centered)
|
|
470
|
+
for line_idx, line in enumerate(lines):
|
|
471
|
+
row_y = y + 1 + line_idx
|
|
472
|
+
canvas.set(x, row_y, chars["vertical"])
|
|
473
|
+
canvas.set(x + actual_width - 1, row_y, chars["vertical"])
|
|
474
|
+
|
|
475
|
+
# Center the text line within the box
|
|
476
|
+
available_width = actual_width - 2 # Minus borders
|
|
477
|
+
text_start = x + 1 + (available_width - len(line)) // 2
|
|
478
|
+
canvas.draw_text(text_start, row_y, line)
|
|
479
|
+
|
|
480
|
+
# Draw bottom border
|
|
481
|
+
canvas.set(x, y + height - 1, chars["bottom_left"])
|
|
482
|
+
for i in range(1, actual_width - 1):
|
|
483
|
+
canvas.set(x + i, y + height - 1, chars["horizontal"])
|
|
484
|
+
canvas.set(x + actual_width - 1, y + height - 1, chars["bottom_right"])
|
|
485
|
+
|
|
486
|
+
return height # Height of the title box
|