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/generator.py ADDED
@@ -0,0 +1,1803 @@
1
+ """
2
+ Main flowchart generator module.
3
+
4
+ Combines parsing, layout, and rendering to produce
5
+ beautiful ASCII flowcharts.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional, Tuple
11
+
12
+ from PIL import Image, ImageDraw, ImageFont
13
+
14
+ from .layout import LayoutResult, NetworkXLayout
15
+ from .parser import Parser
16
+ from .renderer import (
17
+ ARROW_CHARS,
18
+ BOX_CHARS,
19
+ LINE_CHARS,
20
+ BoxDimensions,
21
+ BoxRenderer,
22
+ Canvas,
23
+ TitleRenderer,
24
+ )
25
+
26
+
27
+ @dataclass
28
+ class LayerBoundary:
29
+ """Boundary information for a layer."""
30
+
31
+ layer_idx: int
32
+ top_y: int # Top of layer (where boxes start)
33
+ bottom_y: int # Bottom of layer (including shadow)
34
+ gap_start_y: int # Start of gap below this layer
35
+ gap_end_y: int # End of gap (start of next layer)
36
+
37
+
38
+ @dataclass
39
+ class ColumnBoundary:
40
+ """Boundary information for a column (layer in LR mode)."""
41
+
42
+ layer_idx: int
43
+ left_x: int # Left edge of column (where boxes start)
44
+ right_x: int # Right edge of column (including shadow)
45
+ gap_start_x: int # Start of gap to the right of this column
46
+ gap_end_x: int # End of gap (start of next column)
47
+
48
+
49
+ class FlowchartGenerator:
50
+ """
51
+ Generate ASCII flowcharts from simple text descriptions.
52
+
53
+ Example:
54
+ >>> generator = FlowchartGenerator()
55
+ >>> flowchart = generator.generate('''
56
+ ... A -> B
57
+ ... B -> C
58
+ ... ''')
59
+ >>> print(flowchart)
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ max_text_width: int = 22,
65
+ min_box_width: int = 10,
66
+ horizontal_spacing: int = 12,
67
+ vertical_spacing: int = 3,
68
+ shadow: bool = True,
69
+ rounded: bool = False,
70
+ compact: bool = False,
71
+ font: Optional[str] = None,
72
+ title: Optional[str] = None,
73
+ direction: str = "TB",
74
+ ):
75
+ """
76
+ Initialize the flowchart generator.
77
+
78
+ Args:
79
+ max_text_width: Maximum width for text inside boxes before wrapping
80
+ min_box_width: Minimum box width
81
+ horizontal_spacing: Space between boxes horizontally
82
+ vertical_spacing: Space between boxes vertically
83
+ shadow: Whether to draw box shadows
84
+ rounded: Whether to use rounded corners (╭╮╯╰) instead of square
85
+ compact: Whether to use compact boxes (no vertical padding)
86
+ font: Font name for PNG output (e.g., "Cascadia Code", "Monaco")
87
+ title: Optional title to display above the flowchart
88
+ direction: Flow direction - "TB" (top-to-bottom) or "LR" (left-to-right)
89
+ """
90
+ self.max_text_width = max_text_width
91
+ self.min_box_width = min_box_width
92
+ self.horizontal_spacing = horizontal_spacing
93
+ self.vertical_spacing = vertical_spacing
94
+ self.shadow = shadow
95
+ self.rounded = rounded
96
+ self.compact = compact
97
+ self.font = font
98
+ self.title = title
99
+ self.direction = direction.upper()
100
+
101
+ if self.direction not in ("TB", "LR"):
102
+ raise ValueError(
103
+ "direction must be 'TB' (top-to-bottom) or 'LR' (left-to-right)"
104
+ )
105
+
106
+ self.parser = Parser()
107
+ self.layout_engine = NetworkXLayout()
108
+ self.box_renderer = BoxRenderer(
109
+ max_text_width=max_text_width,
110
+ shadow=shadow,
111
+ rounded=rounded,
112
+ compact=compact,
113
+ )
114
+ self.title_renderer = TitleRenderer()
115
+
116
+ def generate(self, input_text: str, title: Optional[str] = None) -> str:
117
+ """
118
+ Generate an ASCII flowchart from input text.
119
+
120
+ Args:
121
+ input_text: Multi-line string with connections like "A -> B"
122
+ title: Optional title to display (overrides instance title)
123
+
124
+ Returns:
125
+ ASCII art flowchart as a string
126
+ """
127
+ # Use provided title or fall back to instance title
128
+ effective_title = title if title is not None else self.title
129
+
130
+ # Parse input
131
+ connections = self.parser.parse(input_text)
132
+
133
+ # Run layout
134
+ layout_result = self.layout_engine.layout(connections)
135
+
136
+ # Calculate box dimensions for each node
137
+ box_dimensions = self._calculate_all_box_dimensions(layout_result)
138
+
139
+ # Calculate actual pixel positions - leave margin for back edges
140
+ # Each back edge needs 3 chars of space, plus 4 for min line before arrow
141
+ num_back_edges = len(layout_result.back_edges)
142
+ back_edge_margin = (4 + num_back_edges * 3) if num_back_edges > 0 else 0
143
+
144
+ if self.direction == "LR":
145
+ box_positions = self._calculate_positions_horizontal(
146
+ layout_result, box_dimensions, top_margin=back_edge_margin
147
+ )
148
+ else:
149
+ box_positions = self._calculate_positions(
150
+ layout_result, box_dimensions, left_margin=back_edge_margin
151
+ )
152
+
153
+ # Calculate layer boundaries for safe edge routing
154
+ layer_boundaries = self._calculate_layer_boundaries(
155
+ layout_result, box_dimensions
156
+ )
157
+
158
+ # Calculate column boundaries for LR mode
159
+ column_boundaries: List[ColumnBoundary] = []
160
+ if self.direction == "LR":
161
+ column_boundaries = self._calculate_column_boundaries(
162
+ layout_result, box_dimensions
163
+ )
164
+
165
+ # Calculate canvas size
166
+ canvas_width, canvas_height = self._calculate_canvas_size(
167
+ box_dimensions, box_positions
168
+ )
169
+
170
+ # Calculate title dimensions and offset
171
+ title_height = 0
172
+ title_width = 0
173
+ diagram_x_offset = 0
174
+ title_x_offset = 0
175
+
176
+ if effective_title:
177
+ title_width, title_height = self.title_renderer.calculate_title_dimensions(
178
+ effective_title
179
+ )
180
+ title_height += 2 # Add spacing below title
181
+
182
+ # Determine centering: center title above diagram or diagram under title
183
+ if title_width > canvas_width:
184
+ # Title is wider - center diagram under title
185
+ diagram_x_offset = (title_width - canvas_width) // 2
186
+ canvas_width = title_width
187
+ else:
188
+ # Diagram is wider - center title above diagram
189
+ title_x_offset = (canvas_width - title_width) // 2
190
+
191
+ # Create canvas with padding and title space
192
+ canvas = Canvas(canvas_width + 5, canvas_height + title_height + 5)
193
+
194
+ # Draw title if present, centered above the diagram
195
+ if effective_title:
196
+ self.title_renderer.draw_title(
197
+ canvas, title_x_offset, 0, effective_title, title_width
198
+ )
199
+
200
+ # Offset box positions for title and centering
201
+ if title_height > 0 or diagram_x_offset > 0:
202
+ box_positions = {
203
+ name: (x + diagram_x_offset, y + title_height)
204
+ for name, (x, y) in box_positions.items()
205
+ }
206
+ # Also offset layer boundaries by title height
207
+ if title_height > 0:
208
+ layer_boundaries = [
209
+ LayerBoundary(
210
+ layer_idx=lb.layer_idx,
211
+ top_y=lb.top_y + title_height,
212
+ bottom_y=lb.bottom_y + title_height,
213
+ gap_start_y=lb.gap_start_y + title_height,
214
+ gap_end_y=lb.gap_end_y + title_height,
215
+ )
216
+ for lb in layer_boundaries
217
+ ]
218
+ # And column boundaries for LR mode
219
+ if self.direction == "LR" and diagram_x_offset > 0:
220
+ column_boundaries = [
221
+ ColumnBoundary(
222
+ layer_idx=cb.layer_idx,
223
+ left_x=cb.left_x + diagram_x_offset,
224
+ right_x=cb.right_x + diagram_x_offset,
225
+ gap_start_x=cb.gap_start_x + diagram_x_offset,
226
+ gap_end_x=cb.gap_end_x + diagram_x_offset,
227
+ )
228
+ for cb in column_boundaries
229
+ ]
230
+
231
+ # Draw boxes first
232
+ self._draw_boxes(canvas, box_dimensions, box_positions, layout_result)
233
+
234
+ # Draw forward edges with layer-aware routing
235
+ if self.direction == "LR":
236
+ self._draw_edges_horizontal(
237
+ canvas,
238
+ layout_result,
239
+ box_dimensions,
240
+ box_positions,
241
+ column_boundaries,
242
+ title_height,
243
+ )
244
+ else:
245
+ self._draw_edges(
246
+ canvas, layout_result, box_dimensions, box_positions, layer_boundaries
247
+ )
248
+
249
+ # Draw back edges along the margin
250
+ if layout_result.back_edges:
251
+ if self.direction == "LR":
252
+ self._draw_back_edges_horizontal(
253
+ canvas, layout_result, box_dimensions, box_positions, title_height
254
+ )
255
+ else:
256
+ self._draw_back_edges(
257
+ canvas, layout_result, box_dimensions, box_positions
258
+ )
259
+
260
+ return canvas.render()
261
+
262
+ def save_txt(
263
+ self, input_text: str, filename: str, boxes_only: bool = False
264
+ ) -> None:
265
+ """
266
+ Generate flowchart and save to a text file.
267
+
268
+ Args:
269
+ input_text: Multi-line string with connections
270
+ filename: Output filename (should end in .txt)
271
+ boxes_only: If True, only draw boxes without edges (ignored)
272
+ """
273
+ flowchart = self.generate(input_text)
274
+ output_path = Path(filename)
275
+ output_path.write_text(flowchart, encoding="utf-8")
276
+
277
+ def save_png(
278
+ self,
279
+ input_text: str,
280
+ filename: str,
281
+ font_size: int = 16,
282
+ bg_color: str = "#FFFFFF",
283
+ fg_color: str = "#000000",
284
+ padding: int = 20,
285
+ font: Optional[str] = None,
286
+ scale: int = 2,
287
+ ) -> None:
288
+ """
289
+ Generate flowchart and save as a high-resolution PNG image.
290
+
291
+ The PNG rendering is faithful to the ASCII version, using a monospace
292
+ font to preserve the exact character layout and box-drawing characters.
293
+
294
+ Args:
295
+ input_text: Multi-line string with connections like "A -> B"
296
+ filename: Output filename (should end in .png)
297
+ font_size: Font size in points (higher = higher resolution)
298
+ bg_color: Background color as hex string (e.g., "#FFFFFF")
299
+ fg_color: Foreground/text color as hex string (e.g., "#000000")
300
+ padding: Padding around the diagram in pixels
301
+ font: Font name to use (overrides instance font if provided)
302
+ scale: Resolution multiplier for crisp output (default 2 for retina)
303
+
304
+ Example:
305
+ >>> generator = FlowchartGenerator(font="Cascadia Code")
306
+ >>> generator.save_png("A -> B -> C", "flowchart.png", font_size=24)
307
+ """
308
+ ascii_art = self.generate(input_text)
309
+ lines = ascii_art.split("\n")
310
+
311
+ # Use provided font, fall back to instance font, then system defaults
312
+ font_name = font or self.font
313
+ # Apply scale multiplier to font size for higher resolution output
314
+ scaled_font_size = font_size * scale
315
+ loaded_font = self._load_monospace_font(scaled_font_size, font_name)
316
+
317
+ # Calculate character dimensions using a reference character
318
+ bbox = loaded_font.getbbox("M")
319
+ char_width = bbox[2] - bbox[0]
320
+ char_height = bbox[3] - bbox[1]
321
+ line_height = int(char_height * 1.2) # Add some line spacing
322
+
323
+ # Scale padding to match resolution
324
+ scaled_padding = padding * scale
325
+
326
+ # Calculate image dimensions
327
+ max_line_len = max(len(line) for line in lines) if lines else 0
328
+ img_width = char_width * max_line_len + scaled_padding * 2
329
+ img_height = line_height * len(lines) + scaled_padding * 2
330
+
331
+ # Ensure minimum dimensions (scaled)
332
+ img_width = max(img_width, 100 * scale)
333
+ img_height = max(img_height, 100 * scale)
334
+
335
+ # Create image and draw text
336
+ img = Image.new("RGB", (img_width, img_height), bg_color)
337
+ draw = ImageDraw.Draw(img)
338
+
339
+ y = scaled_padding
340
+ for line in lines:
341
+ draw.text((scaled_padding, y), line, font=loaded_font, fill=fg_color)
342
+ y += line_height
343
+
344
+ # Save the image
345
+ output_path = Path(filename)
346
+ img.save(output_path, "PNG")
347
+
348
+ def _load_monospace_font(
349
+ self, font_size: int, font_name: Optional[str] = None
350
+ ) -> ImageFont.FreeTypeFont:
351
+ """
352
+ Load a monospace font for PNG rendering.
353
+
354
+ Tries the following in order:
355
+ 1. User-specified font name if provided
356
+ 2. Common system monospace fonts
357
+ 3. Pillow's default font
358
+
359
+ Args:
360
+ font_size: Font size in points
361
+ font_name: Optional font name (e.g., "Cascadia Code", "Monaco")
362
+
363
+ Returns:
364
+ A PIL ImageFont object
365
+ """
366
+ # Build list of fonts to try
367
+ fonts_to_try = []
368
+
369
+ # Add user-specified font first
370
+ if font_name:
371
+ fonts_to_try.append(font_name)
372
+
373
+ # Common monospace fonts across different systems
374
+ fonts_to_try.extend(
375
+ [
376
+ # Linux
377
+ "DejaVuSansMono",
378
+ "DejaVu Sans Mono",
379
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
380
+ # macOS
381
+ "Monaco",
382
+ "Menlo",
383
+ "/System/Library/Fonts/Monaco.ttf",
384
+ "/System/Library/Fonts/Menlo.ttc",
385
+ # Windows
386
+ "Consolas",
387
+ "Cascadia Code",
388
+ "Courier New",
389
+ "C:/Windows/Fonts/consola.ttf",
390
+ ]
391
+ )
392
+
393
+ for font in fonts_to_try:
394
+ try:
395
+ return ImageFont.truetype(font, font_size)
396
+ except OSError:
397
+ continue
398
+
399
+ # Fall back to Pillow's default font
400
+ try:
401
+ return ImageFont.load_default(size=font_size)
402
+ except TypeError:
403
+ # Older Pillow versions don't support size parameter
404
+ return ImageFont.load_default()
405
+
406
+ def _calculate_all_box_dimensions(
407
+ self, layout_result: LayoutResult
408
+ ) -> Dict[str, BoxDimensions]:
409
+ """Calculate dimensions for all boxes, ensuring minimum size."""
410
+ dimensions = {}
411
+
412
+ for node_name in layout_result.nodes:
413
+ dims = self.box_renderer.calculate_box_dimensions(node_name)
414
+
415
+ # Ensure minimum width
416
+ if dims.width < self.min_box_width:
417
+ dims = BoxDimensions(
418
+ width=self.min_box_width,
419
+ height=dims.height,
420
+ text_lines=dims.text_lines,
421
+ padding=dims.padding,
422
+ )
423
+
424
+ dimensions[node_name] = dims
425
+
426
+ return dimensions
427
+
428
+ def _calculate_positions(
429
+ self,
430
+ layout_result: LayoutResult,
431
+ box_dimensions: Dict[str, BoxDimensions],
432
+ left_margin: int = 0,
433
+ ) -> Dict[str, Tuple[int, int]]:
434
+ """
435
+ Calculate actual x,y positions for each box.
436
+ Centers nodes within each layer.
437
+
438
+ Args:
439
+ layout_result: The layout result from the layout engine
440
+ box_dimensions: Dictionary of box dimensions
441
+ left_margin: Extra space on left for back edge routing
442
+ """
443
+ positions: Dict[str, Tuple[int, int]] = {}
444
+
445
+ # Calculate dimensions for each layer
446
+ layer_heights: List[int] = []
447
+ layer_widths: List[List[int]] = []
448
+
449
+ for layer in layout_result.layers:
450
+ max_height = 0
451
+ widths = []
452
+
453
+ for node_name in layer:
454
+ dims = box_dimensions[node_name]
455
+ # Include shadow in height calculation
456
+ box_height = dims.height + (2 if self.shadow else 0)
457
+ max_height = max(max_height, box_height)
458
+ # Include shadow in width calculation
459
+ box_width = dims.width + (1 if self.shadow else 0)
460
+ widths.append(box_width)
461
+
462
+ layer_heights.append(max_height)
463
+ layer_widths.append(widths)
464
+
465
+ # Calculate cumulative y positions (top of each layer)
466
+ y_positions: List[int] = [0]
467
+ for height in layer_heights[:-1]:
468
+ y_positions.append(y_positions[-1] + height + self.vertical_spacing)
469
+
470
+ # Calculate total width of each layer
471
+ layer_total_widths = []
472
+ for widths in layer_widths:
473
+ if widths:
474
+ total = sum(widths) + self.horizontal_spacing * (len(widths) - 1)
475
+ else:
476
+ total = 0
477
+ layer_total_widths.append(total)
478
+
479
+ # Find maximum layer width for centering
480
+ max_layer_width = max(layer_total_widths) if layer_total_widths else 0
481
+
482
+ # Assign x,y positions
483
+ for layer_idx, layer in enumerate(layout_result.layers):
484
+ widths = layer_widths[layer_idx]
485
+ total_width = layer_total_widths[layer_idx]
486
+
487
+ # Center this layer, plus left margin for back edges
488
+ start_x = left_margin + (max_layer_width - total_width) // 2
489
+
490
+ current_x = start_x
491
+ for pos_idx, node_name in enumerate(layer):
492
+ positions[node_name] = (current_x, y_positions[layer_idx])
493
+ current_x += widths[pos_idx] + self.horizontal_spacing
494
+
495
+ return positions
496
+
497
+ def _calculate_positions_horizontal(
498
+ self,
499
+ layout_result: LayoutResult,
500
+ box_dimensions: Dict[str, BoxDimensions],
501
+ top_margin: int = 0,
502
+ ) -> Dict[str, Tuple[int, int]]:
503
+ """
504
+ Calculate actual x,y positions for each box in horizontal (LR) mode.
505
+ Layers become columns, nodes within a layer stack vertically.
506
+
507
+ Args:
508
+ layout_result: The layout result from the layout engine
509
+ box_dimensions: Dictionary of box dimensions
510
+ top_margin: Extra space on top for back edge routing
511
+ """
512
+ positions: Dict[str, Tuple[int, int]] = {}
513
+
514
+ # Calculate dimensions for each layer (now columns)
515
+ layer_widths: List[int] = [] # Max width per layer (column)
516
+ layer_heights: List[List[int]] = [] # Heights of nodes in each layer
517
+
518
+ for layer in layout_result.layers:
519
+ max_width = 0
520
+ heights = []
521
+
522
+ for node_name in layer:
523
+ dims = box_dimensions[node_name]
524
+ # Include shadow in width calculation
525
+ box_width = dims.width + (1 if self.shadow else 0)
526
+ max_width = max(max_width, box_width)
527
+ # Include shadow in height calculation
528
+ box_height = dims.height + (2 if self.shadow else 0)
529
+ heights.append(box_height)
530
+
531
+ layer_widths.append(max_width)
532
+ layer_heights.append(heights)
533
+
534
+ # Calculate cumulative x positions (left edge of each layer/column)
535
+ x_positions: List[int] = [0]
536
+ for width in layer_widths[:-1]:
537
+ x_positions.append(x_positions[-1] + width + self.horizontal_spacing)
538
+
539
+ # Calculate total height of each layer (column)
540
+ layer_total_heights = []
541
+ for heights in layer_heights:
542
+ if heights:
543
+ total = sum(heights) + self.vertical_spacing * (len(heights) - 1)
544
+ else:
545
+ total = 0
546
+ layer_total_heights.append(total)
547
+
548
+ # Find maximum layer height for centering
549
+ max_layer_height = max(layer_total_heights) if layer_total_heights else 0
550
+
551
+ # Assign x,y positions
552
+ for layer_idx, layer in enumerate(layout_result.layers):
553
+ heights = layer_heights[layer_idx]
554
+ total_height = layer_total_heights[layer_idx]
555
+
556
+ # Center this layer vertically, plus top margin for back edges
557
+ start_y = top_margin + (max_layer_height - total_height) // 2
558
+
559
+ current_y = start_y
560
+ for pos_idx, node_name in enumerate(layer):
561
+ positions[node_name] = (x_positions[layer_idx], current_y)
562
+ current_y += heights[pos_idx] + self.vertical_spacing
563
+
564
+ return positions
565
+
566
+ def _calculate_layer_boundaries(
567
+ self,
568
+ layout_result: LayoutResult,
569
+ box_dimensions: Dict[str, BoxDimensions],
570
+ ) -> List[LayerBoundary]:
571
+ """
572
+ Calculate the y-boundaries for each layer.
573
+
574
+ This information is used for safe edge routing - horizontal segments
575
+ should be placed in the gaps between layers where no boxes exist.
576
+
577
+ Returns:
578
+ List of LayerBoundary objects, one per layer
579
+ """
580
+ boundaries: List[LayerBoundary] = []
581
+
582
+ # Calculate layer heights (same logic as _calculate_positions)
583
+ layer_heights: List[int] = []
584
+ for layer in layout_result.layers:
585
+ max_height = 0
586
+ for node_name in layer:
587
+ dims = box_dimensions[node_name]
588
+ box_height = dims.height + (2 if self.shadow else 0)
589
+ max_height = max(max_height, box_height)
590
+ layer_heights.append(max_height)
591
+
592
+ # Calculate y positions for each layer
593
+ y_positions: List[int] = [0]
594
+ for height in layer_heights[:-1]:
595
+ y_positions.append(y_positions[-1] + height + self.vertical_spacing)
596
+
597
+ # Build boundary objects
598
+ num_layers = len(layout_result.layers)
599
+ for i in range(num_layers):
600
+ top_y = y_positions[i]
601
+ bottom_y = top_y + layer_heights[i] - 1 # -1 because it's inclusive
602
+
603
+ # Gap starts after the shadow (bottom_y is already inclusive of shadow)
604
+ gap_start_y = top_y + layer_heights[i]
605
+
606
+ # Gap ends at the start of the next layer (or canvas edge)
607
+ if i < num_layers - 1:
608
+ gap_end_y = y_positions[i + 1] - 1
609
+ else:
610
+ gap_end_y = gap_start_y + self.vertical_spacing # Last layer
611
+
612
+ boundaries.append(
613
+ LayerBoundary(
614
+ layer_idx=i,
615
+ top_y=top_y,
616
+ bottom_y=bottom_y,
617
+ gap_start_y=gap_start_y,
618
+ gap_end_y=gap_end_y,
619
+ )
620
+ )
621
+
622
+ return boundaries
623
+
624
+ def _calculate_column_boundaries(
625
+ self,
626
+ layout_result: LayoutResult,
627
+ box_dimensions: Dict[str, BoxDimensions],
628
+ ) -> List[ColumnBoundary]:
629
+ """
630
+ Calculate the x-boundaries for each column (layer in LR mode).
631
+
632
+ This information is used for safe edge routing - vertical segments
633
+ should be placed in the gaps between columns where no boxes exist.
634
+
635
+ Returns:
636
+ List of ColumnBoundary objects, one per layer/column
637
+ """
638
+ boundaries: List[ColumnBoundary] = []
639
+
640
+ # Calculate layer widths (same logic as _calculate_positions_horizontal)
641
+ layer_widths: List[int] = []
642
+ for layer in layout_result.layers:
643
+ max_width = 0
644
+ for node_name in layer:
645
+ dims = box_dimensions[node_name]
646
+ box_width = dims.width + (1 if self.shadow else 0)
647
+ max_width = max(max_width, box_width)
648
+ layer_widths.append(max_width)
649
+
650
+ # Calculate x positions for each layer
651
+ x_positions: List[int] = [0]
652
+ for width in layer_widths[:-1]:
653
+ x_positions.append(x_positions[-1] + width + self.horizontal_spacing)
654
+
655
+ # Build boundary objects
656
+ num_layers = len(layout_result.layers)
657
+ for i in range(num_layers):
658
+ left_x = x_positions[i]
659
+ right_x = left_x + layer_widths[i] - 1 # -1 because it's inclusive
660
+
661
+ # Gap starts after the shadow (right_x is already inclusive of shadow)
662
+ gap_start_x = left_x + layer_widths[i]
663
+
664
+ # Gap ends at the start of the next layer (or canvas edge)
665
+ if i < num_layers - 1:
666
+ gap_end_x = x_positions[i + 1] - 1
667
+ else:
668
+ gap_end_x = gap_start_x + self.horizontal_spacing # Last layer
669
+
670
+ boundaries.append(
671
+ ColumnBoundary(
672
+ layer_idx=i,
673
+ left_x=left_x,
674
+ right_x=right_x,
675
+ gap_start_x=gap_start_x,
676
+ gap_end_x=gap_end_x,
677
+ )
678
+ )
679
+
680
+ return boundaries
681
+
682
+ def _calculate_canvas_size(
683
+ self,
684
+ box_dimensions: Dict[str, BoxDimensions],
685
+ box_positions: Dict[str, Tuple[int, int]],
686
+ ) -> Tuple[int, int]:
687
+ """Calculate required canvas dimensions."""
688
+ max_x = 0
689
+ max_y = 0
690
+
691
+ for node_name, (x, y) in box_positions.items():
692
+ dims = box_dimensions[node_name]
693
+ right = x + dims.width + (2 if self.shadow else 0)
694
+ bottom = y + dims.height + (2 if self.shadow else 0)
695
+ max_x = max(max_x, right)
696
+ max_y = max(max_y, bottom)
697
+
698
+ return max_x, max_y
699
+
700
+ def _draw_boxes(
701
+ self,
702
+ canvas: Canvas,
703
+ box_dimensions: Dict[str, BoxDimensions],
704
+ box_positions: Dict[str, Tuple[int, int]],
705
+ layout_result: LayoutResult,
706
+ ) -> None:
707
+ """Draw all boxes on the canvas."""
708
+ for node_name in layout_result.nodes:
709
+ dims = box_dimensions[node_name]
710
+ x, y = box_positions[node_name]
711
+ self.box_renderer.draw_box(canvas, x, y, dims)
712
+
713
+ def _draw_edges(
714
+ self,
715
+ canvas: Canvas,
716
+ layout_result: LayoutResult,
717
+ box_dimensions: Dict[str, BoxDimensions],
718
+ box_positions: Dict[str, Tuple[int, int]],
719
+ layer_boundaries: List[LayerBoundary],
720
+ ) -> None:
721
+ """Draw all forward edges on the canvas (skip back edges)."""
722
+
723
+ # Build lookup for node layers
724
+ node_layer = {name: node.layer for name, node in layout_result.nodes.items()}
725
+
726
+ # Group edges by source to allocate ports properly
727
+ edges_from: Dict[str, List[str]] = {}
728
+ edges_to: Dict[str, List[str]] = {}
729
+
730
+ for source, target in layout_result.edges:
731
+ # Skip back edges (edges going to earlier or same layer)
732
+ if (source, target) in layout_result.back_edges:
733
+ continue
734
+
735
+ src_layer = node_layer.get(source, 0)
736
+ tgt_layer = node_layer.get(target, 0)
737
+
738
+ # Only draw forward edges (target layer > source layer)
739
+ if tgt_layer <= src_layer:
740
+ continue
741
+
742
+ if source not in edges_from:
743
+ edges_from[source] = []
744
+ edges_from[source].append(target)
745
+
746
+ if target not in edges_to:
747
+ edges_to[target] = []
748
+ edges_to[target].append(source)
749
+
750
+ # Sort edges for consistent port allocation
751
+ for source in edges_from:
752
+ edges_from[source].sort(key=lambda t: layout_result.nodes[t].position)
753
+ for target in edges_to:
754
+ edges_to[target].sort(key=lambda s: layout_result.nodes[s].position)
755
+
756
+ # Draw each forward edge
757
+ for source, target in layout_result.edges:
758
+ if (source, target) in layout_result.back_edges:
759
+ continue
760
+
761
+ src_layer = node_layer.get(source, 0)
762
+ tgt_layer = node_layer.get(target, 0)
763
+
764
+ if tgt_layer <= src_layer:
765
+ continue
766
+
767
+ self._draw_edge(
768
+ canvas,
769
+ source,
770
+ target,
771
+ box_dimensions,
772
+ box_positions,
773
+ edges_from.get(source, []),
774
+ edges_to.get(target, []),
775
+ layer_boundaries,
776
+ src_layer,
777
+ tgt_layer,
778
+ layout_result,
779
+ )
780
+
781
+ def _draw_edge(
782
+ self,
783
+ canvas: Canvas,
784
+ source: str,
785
+ target: str,
786
+ box_dimensions: Dict[str, BoxDimensions],
787
+ box_positions: Dict[str, Tuple[int, int]],
788
+ source_targets: List[str],
789
+ target_sources: List[str],
790
+ layer_boundaries: List[LayerBoundary],
791
+ src_layer: int,
792
+ tgt_layer: int,
793
+ layout_result: LayoutResult,
794
+ ) -> None:
795
+ """Draw a single edge from source to target with layer-aware routing."""
796
+ src_dims = box_dimensions[source]
797
+ tgt_dims = box_dimensions[target]
798
+ src_x, src_y = box_positions[source]
799
+ tgt_x, tgt_y = box_positions[target]
800
+
801
+ # Check if boxes overlap horizontally (inside borders)
802
+ src_left = src_x + 1
803
+ src_right = src_x + src_dims.width - 2
804
+ tgt_left = tgt_x + 1
805
+ tgt_right = tgt_x + tgt_dims.width - 2
806
+
807
+ overlap_left = max(src_left, tgt_left)
808
+ overlap_right = min(src_right, tgt_right)
809
+ has_overlap = overlap_left < overlap_right
810
+
811
+ # Check if there are boxes in intermediate layers that would block a direct path
812
+ boxes_in_path = False
813
+ if has_overlap and tgt_layer - src_layer > 1:
814
+ # Check intermediate layers for boxes that would be crossed
815
+ for layer_idx in range(src_layer + 1, tgt_layer):
816
+ for node_name in layout_result.layers[layer_idx]:
817
+ node_dims = box_dimensions[node_name]
818
+ node_x, node_y = box_positions[node_name]
819
+ node_left = node_x
820
+ node_right = node_x + node_dims.width
821
+
822
+ # Check if this box's x range overlaps with the edge's x range
823
+ if node_left < overlap_right and node_right > overlap_left:
824
+ boxes_in_path = True
825
+ break
826
+ if boxes_in_path:
827
+ break
828
+
829
+ if has_overlap and not boxes_in_path:
830
+ # Boxes overlap and no obstructions - find overlapping targets
831
+ overlapping_targets = []
832
+ for t in source_targets:
833
+ t_dims = box_dimensions[t]
834
+ t_x, _ = box_positions[t]
835
+ t_left = t_x + 1
836
+ t_right = t_x + t_dims.width - 2
837
+ t_overlap_left = max(src_left, t_left)
838
+ t_overlap_right = min(src_right, t_right)
839
+ if t_overlap_left < t_overlap_right:
840
+ overlapping_targets.append(t)
841
+
842
+ # Distribute ports within the overlap region for overlapping targets
843
+ overlap_width = overlap_right - overlap_left
844
+ overlap_count = len(overlapping_targets)
845
+ overlap_idx = overlapping_targets.index(target)
846
+
847
+ if overlap_count == 1:
848
+ # Single overlapping target - use center of overlap
849
+ port_x = (overlap_left + overlap_right) // 2
850
+ else:
851
+ # Multiple overlapping targets - distribute within overlap
852
+ if overlap_width >= overlap_count * 2:
853
+ # Enough space to distribute
854
+ spacing = overlap_width // (overlap_count + 1)
855
+ port_x = overlap_left + spacing * (overlap_idx + 1)
856
+ else:
857
+ # Tight space - just use center offset slightly
858
+ port_x = overlap_left + (overlap_width * (overlap_idx + 1)) // (
859
+ overlap_count + 1
860
+ )
861
+
862
+ # Use same x for both source and target (straight line)
863
+ src_port_x = port_x
864
+ tgt_port_x = port_x
865
+ else:
866
+ # No horizontal overlap or boxes in path - use distributed ports
867
+ # Source: exit from bottom
868
+ src_port_count = len(source_targets)
869
+ src_port_idx = source_targets.index(target)
870
+ src_port_x = self._calculate_port_x(
871
+ src_x, src_dims.width, src_port_idx, src_port_count
872
+ )
873
+
874
+ # Target: enter from top
875
+ tgt_port_count = len(target_sources)
876
+ tgt_port_idx = target_sources.index(source)
877
+ tgt_port_x = self._calculate_port_x(
878
+ tgt_x, tgt_dims.width, tgt_port_idx, tgt_port_count
879
+ )
880
+
881
+ src_port_y = src_y + src_dims.height - 1 # Bottom border
882
+ tgt_port_y = tgt_y # Top border
883
+
884
+ # Modify source bottom border to show exit point (tee down)
885
+ canvas.set(src_port_x, src_port_y, LINE_CHARS["tee_down"])
886
+
887
+ # Calculate path
888
+ # Start below source (through shadow - arrow lines overwrite shadows)
889
+ start_y = src_port_y + 1
890
+ # End at target top
891
+ end_y = tgt_port_y
892
+
893
+ # Check if we need to route around boxes (when there are obstructions)
894
+ if boxes_in_path:
895
+ # Route to the right side of all boxes to avoid crossing them
896
+ max_right_x = src_x + src_dims.width
897
+ for layer_idx in range(src_layer + 1, tgt_layer):
898
+ for node_name in layout_result.layers[layer_idx]:
899
+ node_dims = box_dimensions[node_name]
900
+ node_x, _ = box_positions[node_name]
901
+ node_right = node_x + node_dims.width + (2 if self.shadow else 0)
902
+ max_right_x = max(max_right_x, node_right)
903
+
904
+ # Route: down, right to bypass, down, left to target
905
+ route_x = max_right_x + 2 # Go 2 chars to the right of all boxes
906
+
907
+ # Use the mid_y from source layer for the first horizontal segment
908
+ mid_y = self._get_safe_horizontal_y(layer_boundaries, src_layer, start_y)
909
+
910
+ # Vertical from source to mid
911
+ self._draw_vertical_line(canvas, src_port_x, start_y, mid_y - 1)
912
+
913
+ # Corner turning right
914
+ canvas.set(src_port_x, mid_y, LINE_CHARS["corner_bottom_left"])
915
+
916
+ # Horizontal segment to the route column
917
+ self._draw_horizontal_line(canvas, src_port_x, route_x, mid_y)
918
+
919
+ # Corner turning down
920
+ canvas.set(route_x, mid_y, LINE_CHARS["corner_top_right"])
921
+
922
+ # Find the y position for the horizontal segment above the target
923
+ tgt_mid_y = self._get_safe_horizontal_y(
924
+ layer_boundaries, tgt_layer - 1, start_y
925
+ )
926
+
927
+ # Vertical segment down the right side
928
+ self._draw_vertical_line(canvas, route_x, mid_y + 1, tgt_mid_y - 1)
929
+
930
+ # Corner turning left (line comes from above, exits left)
931
+ canvas.set(route_x, tgt_mid_y, LINE_CHARS["corner_bottom_right"])
932
+
933
+ # Horizontal segment back toward target
934
+ self._draw_horizontal_line(canvas, tgt_port_x, route_x, tgt_mid_y)
935
+
936
+ # Corner turning down to target (line comes from right, exits down)
937
+ canvas.set(tgt_port_x, tgt_mid_y, LINE_CHARS["corner_top_left"])
938
+
939
+ # Vertical to target
940
+ self._draw_vertical_line(canvas, tgt_port_x, tgt_mid_y + 1, end_y - 2)
941
+
942
+ # Arrow
943
+ canvas.set(tgt_port_x, tgt_port_y - 1, ARROW_CHARS["down"])
944
+
945
+ elif src_port_x == tgt_port_x:
946
+ # Direct vertical line (stop before arrow position)
947
+ self._draw_vertical_line(canvas, src_port_x, start_y, end_y - 2)
948
+ # Draw arrow one row above target box (doesn't overwrite border)
949
+ canvas.set(tgt_port_x, tgt_port_y - 1, ARROW_CHARS["down"])
950
+ else:
951
+ # Need to route with horizontal segment
952
+ # Use layer-aware routing: place horizontal segment in the gap zone
953
+ # below the source layer where no boxes can exist
954
+ mid_y = self._get_safe_horizontal_y(layer_boundaries, src_layer, start_y)
955
+
956
+ # Vertical from source to mid
957
+ self._draw_vertical_line(canvas, src_port_x, start_y, mid_y - 1)
958
+
959
+ # Corner at source column
960
+ if tgt_port_x > src_port_x:
961
+ canvas.set(src_port_x, mid_y, LINE_CHARS["corner_bottom_left"])
962
+ else:
963
+ canvas.set(src_port_x, mid_y, LINE_CHARS["corner_bottom_right"])
964
+
965
+ # Horizontal segment
966
+ self._draw_horizontal_line(canvas, src_port_x, tgt_port_x, mid_y)
967
+
968
+ # Corner at target column
969
+ if tgt_port_x > src_port_x:
970
+ canvas.set(tgt_port_x, mid_y, LINE_CHARS["corner_top_right"])
971
+ else:
972
+ canvas.set(tgt_port_x, mid_y, LINE_CHARS["corner_top_left"])
973
+
974
+ # Vertical from mid to target (stop before arrow position)
975
+ self._draw_vertical_line(canvas, tgt_port_x, mid_y + 1, end_y - 2)
976
+
977
+ # Draw arrow one row above target box (doesn't overwrite border)
978
+ canvas.set(tgt_port_x, tgt_port_y - 1, ARROW_CHARS["down"])
979
+
980
+ def _get_safe_horizontal_y(
981
+ self,
982
+ layer_boundaries: List[LayerBoundary],
983
+ src_layer: int,
984
+ start_y: int,
985
+ ) -> int:
986
+ """
987
+ Get a safe y-coordinate for horizontal edge routing.
988
+
989
+ Places the horizontal segment in the gap zone below the source layer,
990
+ ensuring it doesn't pass through any boxes.
991
+
992
+ Args:
993
+ layer_boundaries: List of layer boundary information
994
+ src_layer: The layer index of the source node
995
+ start_y: The y-coordinate where the edge starts (below source box)
996
+
997
+ Returns:
998
+ A y-coordinate in the gap zone that's safe for horizontal routing
999
+ """
1000
+ if src_layer < len(layer_boundaries):
1001
+ boundary = layer_boundaries[src_layer]
1002
+ # Place horizontal line in the middle of the gap zone
1003
+ gap_middle = (boundary.gap_start_y + boundary.gap_end_y) // 2
1004
+ # Ensure we're at least at start_y (below the source shadow)
1005
+ return max(gap_middle, start_y + 1)
1006
+ else:
1007
+ # Fallback: just below the start
1008
+ return start_y + 2
1009
+
1010
+ def _get_safe_vertical_x(
1011
+ self,
1012
+ column_boundaries: List[ColumnBoundary],
1013
+ src_layer: int,
1014
+ start_x: int,
1015
+ ) -> int:
1016
+ """
1017
+ Get a safe x-coordinate for vertical edge routing in LR mode.
1018
+
1019
+ Places the vertical segment in the gap zone to the right of the source
1020
+ layer, ensuring it doesn't pass through any boxes.
1021
+
1022
+ Args:
1023
+ column_boundaries: List of column boundary information
1024
+ src_layer: The layer index of the source node
1025
+ start_x: The x-coordinate where the edge starts (after source box)
1026
+
1027
+ Returns:
1028
+ An x-coordinate in the gap zone that's safe for vertical routing
1029
+ """
1030
+ if src_layer < len(column_boundaries):
1031
+ boundary = column_boundaries[src_layer]
1032
+ # Place vertical line in the middle of the gap zone
1033
+ gap_middle = (boundary.gap_start_x + boundary.gap_end_x) // 2
1034
+ # Ensure we're at least at start_x (after the source shadow)
1035
+ return max(gap_middle, start_x + 1)
1036
+ else:
1037
+ # Fallback: just after the start
1038
+ return start_x + 2
1039
+
1040
+ def _calculate_port_x(
1041
+ self, box_x: int, box_width: int, port_idx: int, port_count: int
1042
+ ) -> int:
1043
+ """Calculate x position for a port on a box."""
1044
+ if port_count == 1:
1045
+ # Single port: center of box
1046
+ return box_x + box_width // 2
1047
+ else:
1048
+ # Multiple ports: distribute evenly
1049
+ usable_width = box_width - 4 # Leave margins
1050
+ spacing = usable_width // (port_count + 1)
1051
+ return box_x + 2 + spacing * (port_idx + 1)
1052
+
1053
+ def _draw_vertical_line(
1054
+ self, canvas: Canvas, x: int, y_start: int, y_end: int
1055
+ ) -> None:
1056
+ """Draw a vertical line from y_start to y_end."""
1057
+ if y_start > y_end:
1058
+ y_start, y_end = y_end, y_start
1059
+
1060
+ for y in range(y_start, y_end + 1):
1061
+ current = canvas.get(x, y)
1062
+ if current == LINE_CHARS["horizontal"]:
1063
+ canvas.set(x, y, LINE_CHARS["cross"])
1064
+ elif current == " " or current == BOX_CHARS["shadow"]:
1065
+ canvas.set(x, y, LINE_CHARS["vertical"])
1066
+
1067
+ def _draw_horizontal_line(
1068
+ self, canvas: Canvas, x_start: int, x_end: int, y: int
1069
+ ) -> None:
1070
+ """Draw a horizontal line from x_start to x_end (exclusive of endpoints)."""
1071
+ if x_start > x_end:
1072
+ x_start, x_end = x_end, x_start
1073
+
1074
+ for x in range(x_start + 1, x_end):
1075
+ current = canvas.get(x, y)
1076
+ if current == LINE_CHARS["vertical"]:
1077
+ canvas.set(x, y, LINE_CHARS["cross"])
1078
+ elif current == " " or current == BOX_CHARS["shadow"]:
1079
+ canvas.set(x, y, LINE_CHARS["horizontal"])
1080
+
1081
+ def _draw_back_edges(
1082
+ self,
1083
+ canvas: Canvas,
1084
+ layout_result: LayoutResult,
1085
+ box_dimensions: Dict[str, BoxDimensions],
1086
+ box_positions: Dict[str, Tuple[int, int]],
1087
+ ) -> None:
1088
+ """
1089
+ Draw back edges (cycle edges) along the left margin of the diagram.
1090
+
1091
+ Back edges exit from the bottom-left of the source box, route down
1092
+ then left to the margin, up along the margin, then right and up
1093
+ to enter the target from the left.
1094
+
1095
+ If there are boxes between the margin and the target, the edge routes
1096
+ below those boxes to avoid crossing through them.
1097
+ """
1098
+ if not layout_result.back_edges:
1099
+ return
1100
+
1101
+ margin_x = 2 # Starting route column for back edges
1102
+
1103
+ # Sort back edges by source layer (draw deeper ones first)
1104
+ node_layer = {name: node.layer for name, node in layout_result.nodes.items()}
1105
+
1106
+ sorted_back_edges = sorted(
1107
+ layout_result.back_edges,
1108
+ key=lambda e: node_layer.get(e[0], 0),
1109
+ reverse=True,
1110
+ )
1111
+
1112
+ # Track entries per target for offset
1113
+ target_entry_count: Dict[str, int] = {}
1114
+
1115
+ # Track used margin positions to offset multiple back edges
1116
+ margin_offset = 0
1117
+
1118
+ for source, target in sorted_back_edges:
1119
+ src_dims = box_dimensions[source]
1120
+ tgt_dims = box_dimensions[target]
1121
+ src_x, src_y = box_positions[source]
1122
+ tgt_x, tgt_y = box_positions[target]
1123
+
1124
+ # Use offset margin for multiple back edges
1125
+ route_x = margin_x + margin_offset
1126
+ margin_offset += 3 # Space out multiple back edges (increased for clarity)
1127
+
1128
+ # Track how many edges already entered this target
1129
+ entry_idx = target_entry_count.get(target, 0)
1130
+ target_entry_count[target] = entry_idx + 1
1131
+
1132
+ # Exit point: bottom of source box, then route to left margin
1133
+ exit_border_y = src_y + src_dims.height - 1 # Bottom border
1134
+ exit_below_y = exit_border_y + (2 if self.shadow else 1) # Below shadow
1135
+
1136
+ # Entry point: left side of target box
1137
+ # Offset vertically for multiple entries to same target
1138
+ entry_x = tgt_x
1139
+ base_entry_y = tgt_y + 1 # Start from top of content
1140
+ entry_y = base_entry_y + entry_idx
1141
+
1142
+ # Ensure entry_y is within the box
1143
+ max_entry_y = tgt_y + tgt_dims.height - 2
1144
+ if entry_y > max_entry_y:
1145
+ entry_y = max_entry_y
1146
+
1147
+ # Check if there are boxes between margin and target that would block
1148
+ # the horizontal path at entry_y
1149
+ boxes_in_path = []
1150
+ for node_name, (node_x, node_y) in box_positions.items():
1151
+ if node_name == target:
1152
+ continue
1153
+ node_dims = box_dimensions[node_name]
1154
+ node_right = node_x + node_dims.width + (1 if self.shadow else 0)
1155
+ node_bottom = node_y + node_dims.height + (2 if self.shadow else 0)
1156
+
1157
+ # Check if this box is between margin and target horizontally
1158
+ # AND overlaps with entry_y vertically
1159
+ if node_x > route_x and node_right < entry_x:
1160
+ if node_y <= entry_y < node_bottom:
1161
+ boxes_in_path.append((node_name, node_x, node_y, node_dims))
1162
+
1163
+ # Draw the back edge path:
1164
+ # 1. Mark exit on source bottom-left corner area
1165
+ exit_x = src_x + 1 + (margin_offset - 3) # Offset exit point too
1166
+ if exit_x >= src_x + src_dims.width - 1:
1167
+ exit_x = src_x + 1
1168
+
1169
+ canvas.set(exit_x, exit_border_y, LINE_CHARS["tee_down"])
1170
+
1171
+ # 2. Short vertical line down from source (through shadow)
1172
+ for y in range(exit_border_y + 1, exit_below_y + 1):
1173
+ canvas.set(exit_x, y, LINE_CHARS["vertical"])
1174
+
1175
+ # 3. Corner turning left
1176
+ canvas.set(exit_x, exit_below_y, LINE_CHARS["corner_bottom_right"])
1177
+
1178
+ # 4. Horizontal line left to margin
1179
+ for x in range(route_x + 1, exit_x):
1180
+ current = canvas.get(x, exit_below_y)
1181
+ if current == LINE_CHARS["vertical"]:
1182
+ canvas.set(x, exit_below_y, LINE_CHARS["cross"])
1183
+ elif current == " " or current == BOX_CHARS["shadow"]:
1184
+ canvas.set(x, exit_below_y, LINE_CHARS["horizontal"])
1185
+
1186
+ # 5. Corner at margin (turning up)
1187
+ canvas.set(route_x, exit_below_y, LINE_CHARS["corner_bottom_left"])
1188
+
1189
+ if boxes_in_path:
1190
+ # Need to route around boxes
1191
+ # Strategy: go up to above the target layer (in the gap),
1192
+ # then route horizontally, then down into target from above
1193
+ # This avoids crossing boxes in the same layer as the target
1194
+
1195
+ # Find the top of the target (where we want to enter from above)
1196
+ safe_y = tgt_y - 2 # Position in gap above target layer
1197
+
1198
+ # Find a safe approach x (to the right of blocking boxes)
1199
+ max_blocking_right = max(
1200
+ node_x + node_dims.width + (2 if self.shadow else 1)
1201
+ for _, node_x, _, node_dims in boxes_in_path
1202
+ )
1203
+ # Approach from the right of blocking boxes, but left of target
1204
+ approach_x = min(max_blocking_right + 2, entry_x - 4)
1205
+ if approach_x < route_x + 4:
1206
+ approach_x = route_x + 4
1207
+
1208
+ # 6a. Vertical line up the margin to safe_y
1209
+ for y in range(safe_y + 1, exit_below_y):
1210
+ current = canvas.get(route_x, y)
1211
+ if current == LINE_CHARS["horizontal"]:
1212
+ canvas.set(route_x, y, LINE_CHARS["cross"])
1213
+ elif current == " " or current == BOX_CHARS["shadow"]:
1214
+ canvas.set(route_x, y, LINE_CHARS["vertical"])
1215
+
1216
+ # 7a. Corner at safe_y (turning right)
1217
+ canvas.set(route_x, safe_y, LINE_CHARS["corner_top_left"])
1218
+
1219
+ # 8a. Horizontal line to approach position
1220
+ for x in range(route_x + 1, approach_x):
1221
+ current = canvas.get(x, safe_y)
1222
+ if current == LINE_CHARS["vertical"]:
1223
+ canvas.set(x, safe_y, LINE_CHARS["cross"])
1224
+ elif current == " " or current == BOX_CHARS["shadow"]:
1225
+ canvas.set(x, safe_y, LINE_CHARS["horizontal"])
1226
+
1227
+ # 9a. Corner turning down toward target
1228
+ canvas.set(approach_x, safe_y, LINE_CHARS["corner_top_right"])
1229
+
1230
+ # 10a. Vertical line down to entry level
1231
+ for y in range(safe_y + 1, entry_y):
1232
+ current = canvas.get(approach_x, y)
1233
+ if current == LINE_CHARS["horizontal"]:
1234
+ canvas.set(approach_x, y, LINE_CHARS["cross"])
1235
+ elif current == " " or current == BOX_CHARS["shadow"]:
1236
+ canvas.set(approach_x, y, LINE_CHARS["vertical"])
1237
+
1238
+ # 11a. Corner at entry_y turning right to target
1239
+ canvas.set(approach_x, entry_y, LINE_CHARS["corner_bottom_left"])
1240
+
1241
+ # 12a. Horizontal line to arrow position
1242
+ for x in range(approach_x + 1, entry_x - 1):
1243
+ current = canvas.get(x, entry_y)
1244
+ if current == " " or current == BOX_CHARS["shadow"]:
1245
+ canvas.set(x, entry_y, LINE_CHARS["horizontal"])
1246
+
1247
+ # 13a. Arrow
1248
+ canvas.set(entry_x - 1, entry_y, ARROW_CHARS["right"])
1249
+ else:
1250
+ # No boxes in path - draw directly
1251
+ # 6. Vertical line up the margin
1252
+ for y in range(entry_y + 1, exit_below_y):
1253
+ current = canvas.get(route_x, y)
1254
+ if current == LINE_CHARS["horizontal"]:
1255
+ canvas.set(route_x, y, LINE_CHARS["cross"])
1256
+ elif current == " " or current == BOX_CHARS["shadow"]:
1257
+ canvas.set(route_x, y, LINE_CHARS["vertical"])
1258
+
1259
+ # 7. Corner at target level (turning right)
1260
+ current = canvas.get(route_x, entry_y)
1261
+ if current == LINE_CHARS["vertical"]:
1262
+ canvas.set(route_x, entry_y, LINE_CHARS["tee_right"])
1263
+ elif current == LINE_CHARS["horizontal"]:
1264
+ canvas.set(route_x, entry_y, LINE_CHARS["tee_down"])
1265
+ elif current == " " or current == BOX_CHARS["shadow"]:
1266
+ canvas.set(route_x, entry_y, LINE_CHARS["corner_top_left"])
1267
+
1268
+ # 8. Horizontal line from margin to target
1269
+ for x in range(route_x + 1, entry_x - 1):
1270
+ current = canvas.get(x, entry_y)
1271
+ if current == LINE_CHARS["vertical"]:
1272
+ canvas.set(x, entry_y, LINE_CHARS["cross"])
1273
+ elif current == LINE_CHARS["corner_top_left"]:
1274
+ canvas.set(x, entry_y, LINE_CHARS["tee_down"])
1275
+ elif current == " " or current == BOX_CHARS["shadow"]:
1276
+ canvas.set(x, entry_y, LINE_CHARS["horizontal"])
1277
+
1278
+ # 9. Arrow one column before target box
1279
+ canvas.set(entry_x - 1, entry_y, ARROW_CHARS["right"])
1280
+
1281
+ def _draw_edges_horizontal(
1282
+ self,
1283
+ canvas: Canvas,
1284
+ layout_result: LayoutResult,
1285
+ box_dimensions: Dict[str, BoxDimensions],
1286
+ box_positions: Dict[str, Tuple[int, int]],
1287
+ column_boundaries: List[ColumnBoundary],
1288
+ title_height: int = 0,
1289
+ ) -> None:
1290
+ """Draw all forward edges in horizontal (LR) mode."""
1291
+
1292
+ # Build lookup for node layers
1293
+ node_layer = {name: node.layer for name, node in layout_result.nodes.items()}
1294
+
1295
+ # Group edges by source to allocate ports properly
1296
+ edges_from: Dict[str, List[str]] = {}
1297
+ edges_to: Dict[str, List[str]] = {}
1298
+
1299
+ for source, target in layout_result.edges:
1300
+ # Skip back edges
1301
+ if (source, target) in layout_result.back_edges:
1302
+ continue
1303
+
1304
+ src_layer = node_layer.get(source, 0)
1305
+ tgt_layer = node_layer.get(target, 0)
1306
+
1307
+ # Only draw forward edges (target layer > source layer)
1308
+ if tgt_layer <= src_layer:
1309
+ continue
1310
+
1311
+ if source not in edges_from:
1312
+ edges_from[source] = []
1313
+ edges_from[source].append(target)
1314
+
1315
+ if target not in edges_to:
1316
+ edges_to[target] = []
1317
+ edges_to[target].append(source)
1318
+
1319
+ # Sort edges for consistent port allocation (by vertical position)
1320
+ for source in edges_from:
1321
+ edges_from[source].sort(key=lambda t: box_positions[t][1])
1322
+ for target in edges_to:
1323
+ edges_to[target].sort(key=lambda s: box_positions[s][1])
1324
+
1325
+ # Draw each forward edge
1326
+ for source, target in layout_result.edges:
1327
+ if (source, target) in layout_result.back_edges:
1328
+ continue
1329
+
1330
+ src_layer = node_layer.get(source, 0)
1331
+ tgt_layer = node_layer.get(target, 0)
1332
+
1333
+ if tgt_layer <= src_layer:
1334
+ continue
1335
+
1336
+ self._draw_edge_horizontal(
1337
+ canvas,
1338
+ source,
1339
+ target,
1340
+ box_dimensions,
1341
+ box_positions,
1342
+ edges_from.get(source, []),
1343
+ edges_to.get(target, []),
1344
+ column_boundaries,
1345
+ src_layer,
1346
+ tgt_layer,
1347
+ layout_result,
1348
+ )
1349
+
1350
+ def _draw_edge_horizontal(
1351
+ self,
1352
+ canvas: Canvas,
1353
+ source: str,
1354
+ target: str,
1355
+ box_dimensions: Dict[str, BoxDimensions],
1356
+ box_positions: Dict[str, Tuple[int, int]],
1357
+ source_targets: List[str],
1358
+ target_sources: List[str],
1359
+ column_boundaries: List[ColumnBoundary],
1360
+ src_layer: int,
1361
+ tgt_layer: int,
1362
+ layout_result: LayoutResult,
1363
+ ) -> None:
1364
+ """Draw a single edge from source to target in horizontal (LR) mode."""
1365
+ src_dims = box_dimensions[source]
1366
+ tgt_dims = box_dimensions[target]
1367
+ src_x, src_y = box_positions[source]
1368
+ tgt_x, tgt_y = box_positions[target]
1369
+
1370
+ # Check if boxes overlap vertically (inside borders)
1371
+ src_top = src_y + 1
1372
+ src_bottom = src_y + src_dims.height - 2
1373
+ tgt_top = tgt_y + 1
1374
+ tgt_bottom = tgt_y + tgt_dims.height - 2
1375
+
1376
+ overlap_top = max(src_top, tgt_top)
1377
+ overlap_bottom = min(src_bottom, tgt_bottom)
1378
+ has_overlap = overlap_top < overlap_bottom
1379
+
1380
+ # Check if there are boxes in intermediate columns that would block the path
1381
+ boxes_in_path = False
1382
+ if has_overlap and tgt_layer - src_layer > 1:
1383
+ for layer_idx in range(src_layer + 1, tgt_layer):
1384
+ for node_name in layout_result.layers[layer_idx]:
1385
+ node_dims = box_dimensions[node_name]
1386
+ node_x, node_y = box_positions[node_name]
1387
+ node_top = node_y
1388
+ node_bottom = node_y + node_dims.height
1389
+
1390
+ # Check if this box's y range overlaps with the edge's y range
1391
+ if node_top < overlap_bottom and node_bottom > overlap_top:
1392
+ boxes_in_path = True
1393
+ break
1394
+ if boxes_in_path:
1395
+ break
1396
+
1397
+ if has_overlap and not boxes_in_path:
1398
+ # Boxes overlap vertically and no obstructions
1399
+ overlapping_targets = []
1400
+ for t in source_targets:
1401
+ t_dims = box_dimensions[t]
1402
+ t_x, t_y = box_positions[t]
1403
+ t_top = t_y + 1
1404
+ t_bottom = t_y + t_dims.height - 2
1405
+ t_overlap_top = max(src_top, t_top)
1406
+ t_overlap_bottom = min(src_bottom, t_bottom)
1407
+ if t_overlap_top < t_overlap_bottom:
1408
+ overlapping_targets.append(t)
1409
+
1410
+ # Distribute ports within the overlap region
1411
+ overlap_height = overlap_bottom - overlap_top
1412
+ overlap_count = len(overlapping_targets)
1413
+ overlap_idx = overlapping_targets.index(target)
1414
+
1415
+ if overlap_count == 1:
1416
+ port_y = (overlap_top + overlap_bottom) // 2
1417
+ else:
1418
+ if overlap_height >= overlap_count * 2:
1419
+ spacing = overlap_height // (overlap_count + 1)
1420
+ port_y = overlap_top + spacing * (overlap_idx + 1)
1421
+ else:
1422
+ port_y = overlap_top + (overlap_height * (overlap_idx + 1)) // (
1423
+ overlap_count + 1
1424
+ )
1425
+
1426
+ src_port_y = port_y
1427
+ tgt_port_y = port_y
1428
+ else:
1429
+ # No vertical overlap or boxes in path - use distributed ports
1430
+ src_port_count = len(source_targets)
1431
+ src_port_idx = source_targets.index(target)
1432
+ src_port_y = self._calculate_port_y(
1433
+ src_y, src_dims.height, src_port_idx, src_port_count
1434
+ )
1435
+
1436
+ tgt_port_count = len(target_sources)
1437
+ tgt_port_idx = target_sources.index(source)
1438
+ tgt_port_y = self._calculate_port_y(
1439
+ tgt_y, tgt_dims.height, tgt_port_idx, tgt_port_count
1440
+ )
1441
+
1442
+ # Exit from right side of source box
1443
+ src_port_x = src_x + src_dims.width - 1
1444
+ # Enter left side of target box
1445
+ tgt_port_x = tgt_x
1446
+
1447
+ # Modify source right border to show exit point (tee right)
1448
+ canvas.set(src_port_x, src_port_y, LINE_CHARS["tee_left"])
1449
+
1450
+ # Calculate path
1451
+ start_x = src_port_x + 1 # After source (through shadow)
1452
+ end_x = tgt_port_x
1453
+
1454
+ # Check if we need to route around boxes
1455
+ if boxes_in_path:
1456
+ # Route below all boxes to avoid crossing them
1457
+ max_bottom_y = src_y + src_dims.height
1458
+ for layer_idx in range(src_layer + 1, tgt_layer):
1459
+ for node_name in layout_result.layers[layer_idx]:
1460
+ node_dims = box_dimensions[node_name]
1461
+ node_x, node_y = box_positions[node_name]
1462
+ node_bottom = node_y + node_dims.height + (2 if self.shadow else 0)
1463
+ max_bottom_y = max(max_bottom_y, node_bottom)
1464
+
1465
+ # Route: right, down to bypass, right, up to target
1466
+ route_y = max_bottom_y + 2 # Go 2 rows below all boxes
1467
+
1468
+ # Use the mid_x from source layer for the first vertical segment
1469
+ mid_x = self._get_safe_vertical_x(column_boundaries, src_layer, start_x)
1470
+
1471
+ # Horizontal from source to mid
1472
+ self._draw_horizontal_line(canvas, start_x - 1, mid_x, src_port_y)
1473
+
1474
+ # Corner turning down
1475
+ canvas.set(mid_x, src_port_y, LINE_CHARS["corner_top_right"])
1476
+
1477
+ # Vertical segment down to route_y
1478
+ self._draw_vertical_line(canvas, mid_x, src_port_y + 1, route_y - 1)
1479
+
1480
+ # Corner turning right
1481
+ canvas.set(mid_x, route_y, LINE_CHARS["corner_bottom_left"])
1482
+
1483
+ # Find the x position for the vertical segment before the target
1484
+ tgt_mid_x = self._get_safe_vertical_x(
1485
+ column_boundaries, tgt_layer - 1, start_x
1486
+ )
1487
+
1488
+ # Horizontal segment below boxes
1489
+ self._draw_horizontal_line(canvas, mid_x, tgt_mid_x, route_y)
1490
+
1491
+ # Corner turning up
1492
+ canvas.set(tgt_mid_x, route_y, LINE_CHARS["corner_bottom_right"])
1493
+
1494
+ # Vertical segment up toward target
1495
+ self._draw_vertical_line(canvas, tgt_mid_x, tgt_port_y + 1, route_y - 1)
1496
+
1497
+ # Corner turning right to target
1498
+ canvas.set(tgt_mid_x, tgt_port_y, LINE_CHARS["corner_top_left"])
1499
+
1500
+ # Horizontal to target
1501
+ self._draw_horizontal_line(canvas, tgt_mid_x, end_x - 1, tgt_port_y)
1502
+
1503
+ # Arrow
1504
+ canvas.set(tgt_port_x - 1, tgt_port_y, ARROW_CHARS["right"])
1505
+
1506
+ elif src_port_y == tgt_port_y:
1507
+ # Direct horizontal line
1508
+ # Note: _draw_horizontal_line is exclusive of endpoints, so we adjust
1509
+ # start_x - 1 so the line begins at start_x, and end_x - 1 so it ends
1510
+ # at end_x - 2 (one position before the arrow at end_x - 1)
1511
+ self._draw_horizontal_line(canvas, start_x - 1, end_x - 1, src_port_y)
1512
+ canvas.set(tgt_port_x - 1, tgt_port_y, ARROW_CHARS["right"])
1513
+ else:
1514
+ # Need to route with vertical segment
1515
+ # Use column-aware routing: place vertical segment in the gap zone
1516
+ # to the right of the source layer where no boxes can exist
1517
+ mid_x = self._get_safe_vertical_x(column_boundaries, src_layer, start_x)
1518
+
1519
+ # Horizontal from source to mid
1520
+ # Adjust for exclusive endpoints: start_x - 1 so line begins at start_x,
1521
+ # mid_x so line ends at mid_x - 1 (just before the corner at mid_x)
1522
+ self._draw_horizontal_line(canvas, start_x - 1, mid_x, src_port_y)
1523
+
1524
+ # Corner at source row
1525
+ if tgt_port_y > src_port_y:
1526
+ canvas.set(mid_x, src_port_y, LINE_CHARS["corner_top_right"])
1527
+ else:
1528
+ canvas.set(mid_x, src_port_y, LINE_CHARS["corner_bottom_right"])
1529
+
1530
+ # Vertical segment
1531
+ self._draw_vertical_line(canvas, mid_x, src_port_y, tgt_port_y)
1532
+
1533
+ # Corner at target row
1534
+ if tgt_port_y > src_port_y:
1535
+ canvas.set(mid_x, tgt_port_y, LINE_CHARS["corner_bottom_left"])
1536
+ else:
1537
+ canvas.set(mid_x, tgt_port_y, LINE_CHARS["corner_top_left"])
1538
+
1539
+ # Horizontal from mid to target
1540
+ # Adjust for exclusive endpoints: mid_x so line begins at mid_x + 1,
1541
+ # end_x - 1 so line ends at end_x - 2 (just before the arrow at end_x - 1)
1542
+ self._draw_horizontal_line(canvas, mid_x, end_x - 1, tgt_port_y)
1543
+
1544
+ # Arrow
1545
+ canvas.set(tgt_port_x - 1, tgt_port_y, ARROW_CHARS["right"])
1546
+
1547
+ def _calculate_port_y(
1548
+ self, box_y: int, box_height: int, port_idx: int, port_count: int
1549
+ ) -> int:
1550
+ """Calculate y position for a port on a box (horizontal mode)."""
1551
+ if port_count == 1:
1552
+ return box_y + box_height // 2
1553
+ else:
1554
+ usable_height = box_height - 4
1555
+ spacing = usable_height // (port_count + 1)
1556
+ return box_y + 2 + spacing * (port_idx + 1)
1557
+
1558
+ def _draw_back_edges_horizontal(
1559
+ self,
1560
+ canvas: Canvas,
1561
+ layout_result: LayoutResult,
1562
+ box_dimensions: Dict[str, BoxDimensions],
1563
+ box_positions: Dict[str, Tuple[int, int]],
1564
+ title_height: int = 0,
1565
+ ) -> None:
1566
+ """
1567
+ Draw back edges (cycle edges) along the top margin in horizontal mode.
1568
+
1569
+ Back edges exit from the top-right of the source box, route up
1570
+ to the margin, left along the margin, then down to enter the
1571
+ target from the top.
1572
+
1573
+ If there are boxes between the margin and the target, the edge routes
1574
+ to the right of those boxes to avoid crossing through them.
1575
+ """
1576
+ if not layout_result.back_edges:
1577
+ return
1578
+
1579
+ margin_y = 2 + title_height # Starting route row for back edges
1580
+
1581
+ # Sort back edges by source layer (draw deeper ones first)
1582
+ node_layer = {name: node.layer for name, node in layout_result.nodes.items()}
1583
+
1584
+ sorted_back_edges = sorted(
1585
+ layout_result.back_edges,
1586
+ key=lambda e: node_layer.get(e[0], 0),
1587
+ reverse=True,
1588
+ )
1589
+
1590
+ # Track entries per target for offset
1591
+ target_entry_count: Dict[str, int] = {}
1592
+
1593
+ # Track used margin positions to offset multiple back edges
1594
+ margin_offset = 0
1595
+
1596
+ for source, target in sorted_back_edges:
1597
+ src_dims = box_dimensions[source]
1598
+ tgt_dims = box_dimensions[target]
1599
+ src_x, src_y = box_positions[source]
1600
+ tgt_x, tgt_y = box_positions[target]
1601
+
1602
+ # Use offset margin for multiple back edges
1603
+ route_y = margin_y + margin_offset
1604
+ margin_offset += 3 # Space out multiple back edges (increased for clarity)
1605
+
1606
+ # Track how many edges already entered this target
1607
+ entry_idx = target_entry_count.get(target, 0)
1608
+ target_entry_count[target] = entry_idx + 1
1609
+
1610
+ # Exit point: right side of source box, near top
1611
+ exit_border_x = src_x + src_dims.width - 1
1612
+ exit_right_x = exit_border_x + (2 if self.shadow else 1)
1613
+
1614
+ # Entry point: top side of target box
1615
+ entry_y = tgt_y
1616
+ base_entry_x = tgt_x + 1
1617
+ entry_x = base_entry_x + entry_idx
1618
+
1619
+ # Ensure entry_x is within the box
1620
+ max_entry_x = tgt_x + tgt_dims.width - 2
1621
+ if entry_x > max_entry_x:
1622
+ entry_x = max_entry_x
1623
+
1624
+ # Check if there are boxes between margin and target that would block
1625
+ # the vertical path at entry_x
1626
+ boxes_in_descent_path = []
1627
+ for node_name, (node_x, node_y) in box_positions.items():
1628
+ if node_name == target:
1629
+ continue
1630
+ node_dims = box_dimensions[node_name]
1631
+ node_right = node_x + node_dims.width + (1 if self.shadow else 0)
1632
+ node_bottom = node_y + node_dims.height + (2 if self.shadow else 0)
1633
+
1634
+ # Check if this box is between margin and target vertically
1635
+ # AND overlaps with entry_x horizontally
1636
+ if node_y > route_y and node_bottom < entry_y:
1637
+ if node_x <= entry_x < node_right:
1638
+ boxes_in_descent_path.append(
1639
+ (node_name, node_x, node_y, node_dims)
1640
+ )
1641
+
1642
+ # Draw the back edge path:
1643
+ # 1. Mark exit on source right side near top
1644
+ exit_y = src_y + 1 + (margin_offset - 3)
1645
+ if exit_y >= src_y + src_dims.height - 1:
1646
+ exit_y = src_y + 1
1647
+
1648
+ canvas.set(exit_border_x, exit_y, LINE_CHARS["tee_left"])
1649
+
1650
+ # Check if there are boxes between source and margin that would block
1651
+ # the upward vertical path at exit_right_x
1652
+ boxes_in_ascent_path = []
1653
+ for node_name, (node_x, node_y) in box_positions.items():
1654
+ if node_name == source:
1655
+ continue
1656
+ node_dims = box_dimensions[node_name]
1657
+ node_right = node_x + node_dims.width + (1 if self.shadow else 0)
1658
+ node_bottom = node_y + node_dims.height + (2 if self.shadow else 0)
1659
+
1660
+ # Check if this box is between margin and source vertically
1661
+ # AND overlaps with exit_right_x horizontally
1662
+ if node_y > route_y and node_bottom < exit_y:
1663
+ if node_x <= exit_right_x < node_right:
1664
+ boxes_in_ascent_path.append(
1665
+ (node_name, node_x, node_y, node_dims)
1666
+ )
1667
+
1668
+ if boxes_in_ascent_path:
1669
+ # Need to route around boxes on the way up to the margin
1670
+ # Strategy: go further right past all blocking boxes, then go up
1671
+
1672
+ # Find the rightmost edge of blocking boxes
1673
+ max_blocking_right = max(
1674
+ node_x + node_dims.width + (2 if self.shadow else 1)
1675
+ for _, node_x, _, node_dims in boxes_in_ascent_path
1676
+ )
1677
+
1678
+ # Turn up position: to the right of all blocking boxes
1679
+ turn_up_x = max_blocking_right + 1
1680
+
1681
+ # 2a. Horizontal line right from source to turn_up_x
1682
+ for x in range(exit_border_x + 1, turn_up_x):
1683
+ current = canvas.get(x, exit_y)
1684
+ if current == " " or current == BOX_CHARS["shadow"]:
1685
+ canvas.set(x, exit_y, LINE_CHARS["horizontal"])
1686
+
1687
+ # 3a. Corner turning up at turn_up_x
1688
+ canvas.set(turn_up_x, exit_y, LINE_CHARS["corner_bottom_right"])
1689
+
1690
+ # 4a. Vertical line up to margin
1691
+ for y in range(route_y + 1, exit_y):
1692
+ current = canvas.get(turn_up_x, y)
1693
+ if current == LINE_CHARS["horizontal"]:
1694
+ canvas.set(turn_up_x, y, LINE_CHARS["cross"])
1695
+ elif current == " " or current == BOX_CHARS["shadow"]:
1696
+ canvas.set(turn_up_x, y, LINE_CHARS["vertical"])
1697
+
1698
+ # 5a. Corner at margin (turning left)
1699
+ canvas.set(turn_up_x, route_y, LINE_CHARS["corner_top_right"])
1700
+
1701
+ # Update exit_right_x for the horizontal line along margin
1702
+ exit_right_x = turn_up_x
1703
+ else:
1704
+ # No boxes in ascent path - draw directly
1705
+ # 2. Short horizontal line right from source (through shadow)
1706
+ for x in range(exit_border_x + 1, exit_right_x + 1):
1707
+ canvas.set(x, exit_y, LINE_CHARS["horizontal"])
1708
+
1709
+ # 3. Corner turning up (line enters from left, exits upward)
1710
+ canvas.set(exit_right_x, exit_y, LINE_CHARS["corner_bottom_right"])
1711
+
1712
+ # 4. Vertical line up to margin
1713
+ for y in range(route_y + 1, exit_y):
1714
+ current = canvas.get(exit_right_x, y)
1715
+ if current == LINE_CHARS["horizontal"]:
1716
+ canvas.set(exit_right_x, y, LINE_CHARS["cross"])
1717
+ elif current == " " or current == BOX_CHARS["shadow"]:
1718
+ canvas.set(exit_right_x, y, LINE_CHARS["vertical"])
1719
+
1720
+ # 5. Corner at margin (turning left)
1721
+ canvas.set(exit_right_x, route_y, LINE_CHARS["corner_top_right"])
1722
+
1723
+ # 6. Horizontal line left along the margin
1724
+ for x in range(entry_x + 1, exit_right_x):
1725
+ current = canvas.get(x, route_y)
1726
+ if current == LINE_CHARS["vertical"]:
1727
+ canvas.set(x, route_y, LINE_CHARS["cross"])
1728
+ elif current == " " or current == BOX_CHARS["shadow"]:
1729
+ canvas.set(x, route_y, LINE_CHARS["horizontal"])
1730
+
1731
+ if boxes_in_descent_path:
1732
+ # Need to route around boxes
1733
+ # Strategy: continue LEFT on the margin past all blocking boxes,
1734
+ # then turn down and enter the target from the left side.
1735
+ # This avoids crossing boxes in the same column as the target.
1736
+
1737
+ # Find the leftmost x of all blocking boxes
1738
+ min_blocking_left = min(
1739
+ node_x for _, node_x, _, _ in boxes_in_descent_path
1740
+ )
1741
+
1742
+ # Turn down position: to the left of all blocking boxes
1743
+ turn_down_x = min_blocking_left - 2
1744
+
1745
+ # Calculate entry y position inside the target box
1746
+ target_entry_y = tgt_y + 1 + entry_idx
1747
+ max_target_entry_y = tgt_y + tgt_dims.height - 2
1748
+ if target_entry_y > max_target_entry_y:
1749
+ target_entry_y = max_target_entry_y
1750
+
1751
+ # Continue horizontal line from entry_x to turn_down_x
1752
+ # (the original line was drawn from exit_right_x to entry_x+1)
1753
+ for x in range(turn_down_x + 1, entry_x + 1):
1754
+ current = canvas.get(x, route_y)
1755
+ if current == LINE_CHARS["vertical"]:
1756
+ canvas.set(x, route_y, LINE_CHARS["cross"])
1757
+ elif current == " " or current == BOX_CHARS["shadow"]:
1758
+ canvas.set(x, route_y, LINE_CHARS["horizontal"])
1759
+
1760
+ # 7a. Corner at turn_down_x, route_y (turning down)
1761
+ canvas.set(turn_down_x, route_y, LINE_CHARS["corner_top_left"])
1762
+
1763
+ # 8a. Vertical line down to target_entry_y
1764
+ for y in range(route_y + 1, target_entry_y):
1765
+ current = canvas.get(turn_down_x, y)
1766
+ if current == LINE_CHARS["horizontal"]:
1767
+ canvas.set(turn_down_x, y, LINE_CHARS["cross"])
1768
+ elif current == " " or current == BOX_CHARS["shadow"]:
1769
+ canvas.set(turn_down_x, y, LINE_CHARS["vertical"])
1770
+
1771
+ # 9a. Corner at turn_down_x, target_entry_y (turning right)
1772
+ corner_char = LINE_CHARS["corner_bottom_left"]
1773
+ canvas.set(turn_down_x, target_entry_y, corner_char)
1774
+
1775
+ # 10a. Horizontal line to arrow position
1776
+ for x in range(turn_down_x + 1, tgt_x - 1):
1777
+ current = canvas.get(x, target_entry_y)
1778
+ if current == " " or current == BOX_CHARS["shadow"]:
1779
+ canvas.set(x, target_entry_y, LINE_CHARS["horizontal"])
1780
+
1781
+ # 11a. Arrow (entering from left)
1782
+ canvas.set(tgt_x - 1, target_entry_y, ARROW_CHARS["right"])
1783
+ else:
1784
+ # No boxes in path - draw directly
1785
+ # 7. Corner at target column (turning down)
1786
+ current = canvas.get(entry_x, route_y)
1787
+ if current == LINE_CHARS["horizontal"]:
1788
+ canvas.set(entry_x, route_y, LINE_CHARS["tee_down"])
1789
+ elif current == LINE_CHARS["vertical"]:
1790
+ canvas.set(entry_x, route_y, LINE_CHARS["tee_down"])
1791
+ elif current == " " or current == BOX_CHARS["shadow"]:
1792
+ canvas.set(entry_x, route_y, LINE_CHARS["corner_top_left"])
1793
+
1794
+ # 8. Vertical line from margin to target (stop before arrow)
1795
+ for y in range(route_y + 1, entry_y - 1):
1796
+ current = canvas.get(entry_x, y)
1797
+ if current == LINE_CHARS["horizontal"]:
1798
+ canvas.set(entry_x, y, LINE_CHARS["cross"])
1799
+ elif current == " " or current == BOX_CHARS["shadow"]:
1800
+ canvas.set(entry_x, y, LINE_CHARS["vertical"])
1801
+
1802
+ # 9. Arrow one row above target box
1803
+ canvas.set(entry_x, entry_y - 1, ARROW_CHARS["down"])