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