Coverage for little_loops / cli / loop / layout.py: 4%
1223 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""FSM diagram layout engine.
3Implements adaptive layout for FSM diagrams using a Sugiyama-inspired
4layered graph drawing approach. Supports topology detection, vertical
5linear chains, side-by-side branching, and margin back-edge rendering.
7Extracted from info.py and extended with adaptive layout capabilities.
8"""
10from __future__ import annotations
12import re
13from collections import deque
15from wcwidth import wcswidth as _wcswidth
16from wcwidth import wcwidth as _wcwidth
18from little_loops.cli.output import colorize, strip_ansi, terminal_width
19from little_loops.fsm.schema import FSMLoop, StateConfig
21# ---------------------------------------------------------------------------
22# Edge label colorization
23# ---------------------------------------------------------------------------
25_EDGE_LABEL_COLORS: dict[str, str] = {
26 "yes": "32",
27 "no": "38;5;208",
28 "error": "31",
29 "partial": "33",
30 "next": "2",
31 "_": "2",
32 "blocked": "31",
33 "retry_exhausted": "38;5;208",
34 "rate_limit_exhausted": "38;5;214",
35 "throttle_hard": "38;5;196",
36}
39def _colorize_label(label: str) -> str:
40 """Colorize a (possibly compound) edge label like 'no/error'."""
41 parts = label.split("/")
42 code = ""
43 for part in parts:
44 if part in ("no", "error"):
45 code = _EDGE_LABEL_COLORS["no"]
46 break
47 if part == "partial" and not code:
48 code = _EDGE_LABEL_COLORS["partial"]
49 elif part == "yes" and not code:
50 code = _EDGE_LABEL_COLORS["yes"]
51 elif part in ("next", "_") and not code:
52 code = _EDGE_LABEL_COLORS["next"]
53 return colorize(label, code) if code else label
56def _edge_line_color(label: str) -> str:
57 """Return the ANSI SGR code to use for connector characters of an edge.
59 Applies the same priority cascade as ``_colorize_label`` so that line
60 characters (│, ─, ▼, ▶, corners) match the semantic color of their label.
61 Returns an empty string when no color applies (callers treat this as "no-op").
62 """
63 parts = label.split("/")
64 code = ""
65 for part in parts:
66 if part in (
67 "no",
68 "error",
69 "blocked",
70 "retry_exhausted",
71 "rate_limit_exhausted",
72 "throttle_hard",
73 ):
74 return _EDGE_LABEL_COLORS.get(part, "31")
75 if part == "partial" and not code:
76 code = _EDGE_LABEL_COLORS["partial"]
77 elif part == "yes" and not code:
78 code = _EDGE_LABEL_COLORS["yes"]
79 elif part in ("next", "_") and not code:
80 code = _EDGE_LABEL_COLORS["next"]
81 return code
84def _colorize_diagram_labels(diagram: str, colors: dict[str, str] | None = None) -> str:
85 """Apply ANSI color to known edge labels in a rendered diagram string.
87 Labels are colorized only when bounded by box-drawing or whitespace chars
88 to avoid coloring partial matches inside state names.
90 Args:
91 colors: Optional label→SGR-code mapping; falls back to _EDGE_LABEL_COLORS if None.
92 """
93 label_colors = colors if colors is not None else _EDGE_LABEL_COLORS
94 for label, code in label_colors.items():
95 colored = colorize(label, code)
96 diagram = re.sub(
97 r"(?<=[─ │▶\n])" + re.escape(label) + r"(?=[─ │▶\n])",
98 colored,
99 diagram,
100 )
101 return diagram
104# ---------------------------------------------------------------------------
105# State box badge definitions
106# ---------------------------------------------------------------------------
108_ACTION_TYPE_BADGES: dict[str, str] = {
109 "prompt": "\u2726", # ✦
110 "slash_command": "/\u2501\u25ba", # /━►
111 "shell": "\u276f_", # ❯_
112 "mcp_tool": "\u26a1", # ⚡
113}
115_SUB_LOOP_BADGE = "\u21b3\u27f3" # ↳⟳
116_ROUTE_BADGE = "\u2443" # ⑃
119def _badge_display_width(badge: str) -> int:
120 """Compute terminal display width of a badge string using wcwidth."""
121 w = _wcswidth(badge)
122 return w if w >= 0 else len(badge)
125def _display_width(s: str) -> int:
126 """Terminal display width of a string (wcwidth), with a safe len() fallback.
128 Box layout reserves *display columns*, not characters, so widths must be
129 measured this way; using ``len()`` undercounts wide glyphs (CJK, some
130 symbols) and overflows the box border.
131 """
132 w = _wcswidth(s)
133 return w if w >= 0 else len(s)
136def _truncate_to_width(text: str, width: int) -> str:
137 """Truncate ``text`` so its display width is ≤ ``width``.
139 When truncation occurs the last column is replaced with ``…`` so the result
140 still fits ``width`` display columns. Wide glyphs are kept whole.
141 """
142 if width <= 0:
143 return ""
144 if _display_width(text) <= width:
145 return text
146 # Reserve one column for the ellipsis.
147 budget = width - 1
148 out: list[str] = []
149 used = 0
150 for ch in text:
151 cw = _wcwidth(ch)
152 if cw < 0:
153 cw = 1
154 if used + cw > budget:
155 break
156 out.append(ch)
157 used += cw
158 return "".join(out) + "…"
161def _wrap_to_width(text: str, width: int) -> list[str]:
162 """Hard-wrap ``text`` into chunks each ≤ ``width`` display columns.
164 Splits on display width (not character count) so wide glyphs are kept whole
165 and no chunk overflows the box border.
166 """
167 if width <= 0:
168 return [text] if text else []
169 chunks: list[str] = []
170 cur: list[str] = []
171 used = 0
172 for ch in text:
173 cw = _wcwidth(ch)
174 if cw < 0:
175 cw = 1
176 if used + cw > width and cur:
177 chunks.append("".join(cur))
178 cur = []
179 used = 0
180 cur.append(ch)
181 used += cw
182 if cur:
183 chunks.append("".join(cur))
184 return chunks
187def _get_state_badge(state: StateConfig | None, badges: dict[str, str] | None = None) -> str:
188 """Return the unicode badge string for a state, or '' if none."""
189 if state is None:
190 return ""
191 effective = {**_ACTION_TYPE_BADGES, **(badges or {})}
192 sub_loop_badge = (badges or {}).get("sub_loop", _SUB_LOOP_BADGE)
193 route_badge = (badges or {}).get("route", _ROUTE_BADGE)
194 if state.loop is not None:
195 return sub_loop_badge
196 if state.action_type:
197 return effective.get(state.action_type, f"[{state.action_type}]")
198 if state.action:
199 return effective["shell"]
200 if state.route is not None:
201 return route_badge
202 return ""
205# ---------------------------------------------------------------------------
206# Box content helpers for multi-row diagram boxes
207# ---------------------------------------------------------------------------
210def _box_inner_lines(
211 state: StateConfig | None,
212 display_label: str,
213 verbose: bool,
214 inner_width: int,
215 title_only: bool = False,
216) -> list[str]:
217 """Return interior lines for a state box (between top and bottom borders).
219 The first line is always ``display_label`` + type badge (if any).
220 Subsequent lines are action content lines. All lines fit within
221 ``inner_width`` characters (content is truncated or wrapped accordingly).
223 When ``title_only`` is True, only the name row is returned (used by
224 ``--show-diagrams=mini`` for skeleton rendering).
225 """
226 # Badge is now rendered in the top-right corner by _draw_box; name row is label only.
227 # Truncation is by display width so wide glyphs don't overflow the box border.
228 name_line = _truncate_to_width(display_label, inner_width)
230 lines: list[str] = [name_line]
232 if title_only:
233 return lines
235 # Action lines
236 if state and state.action:
237 action_src = [ln.rstrip() for ln in state.action.strip().splitlines()]
238 if verbose:
239 for src in action_src:
240 if not src:
241 continue
242 # Wrap long lines to inner_width (measured in display columns)
243 lines.extend(_wrap_to_width(src, inner_width))
244 else:
245 # Show first non-empty line, truncated to display width
246 first = next((ln for ln in action_src if ln), "")
247 first = _truncate_to_width(first, inner_width)
248 if first:
249 lines.append(first)
251 return lines
254# ---------------------------------------------------------------------------
255# Topology detection
256# ---------------------------------------------------------------------------
259def _collect_edges(fsm: FSMLoop) -> list[tuple[str, str, str]]:
260 """Collect all (source, target, label) edges from an FSM."""
261 edges: list[tuple[str, str, str]] = []
262 for name, state in fsm.states.items():
263 if state.on_yes:
264 edges.append((name, state.on_yes, "yes"))
265 if state.on_no:
266 edges.append((name, state.on_no, "no"))
267 if state.on_error:
268 edges.append((name, state.on_error, "error"))
269 if state.on_partial:
270 edges.append((name, state.on_partial, "partial"))
271 if state.on_blocked:
272 edges.append((name, state.on_blocked, "blocked"))
273 if state.on_retry_exhausted:
274 edges.append((name, state.on_retry_exhausted, "retry_exhausted"))
275 if state.on_rate_limit_exhausted:
276 edges.append((name, state.on_rate_limit_exhausted, "rate_limit_exhausted"))
277 if state.on_throttle_hard:
278 edges.append((name, state.on_throttle_hard, "throttle_hard"))
279 if state.next:
280 edges.append((name, state.next, "next"))
281 if state.route:
282 for verdict, target in state.route.routes.items():
283 edges.append((name, target, verdict))
284 if state.route.default:
285 edges.append((name, state.route.default, "_"))
286 for verdict, target in state.extra_routes.items():
287 edges.append((name, target, verdict))
288 return edges
291def _bfs_order(initial: str, edges: list[tuple[str, str, str]]) -> tuple[list[str], dict[str, int]]:
292 """BFS from initial state. Returns (order, depth_map)."""
293 order: list[str] = []
294 depth: dict[str, int] = {initial: 0}
295 queue: deque[str] = deque([initial])
296 while queue:
297 node = queue.popleft()
298 order.append(node)
299 for src, dst, _ in edges:
300 if src == node and dst not in depth:
301 depth[dst] = depth[node] + 1
302 queue.append(dst)
303 return order, depth
306def _trace_main_path(
307 fsm: FSMLoop, edges: list[tuple[str, str, str]]
308) -> tuple[list[str], set[tuple[str, str]]]:
309 """Trace the main (happy) path through the FSM."""
310 visited: set[str] = set()
311 main_path: list[str] = []
312 main_edge_set: set[tuple[str, str]] = set()
313 current = fsm.initial
314 while current and current not in visited:
315 visited.add(current)
316 main_path.append(current)
317 st = fsm.states.get(current)
318 if not st or st.terminal:
319 break
320 nxt: str = st.on_yes or st.next or ""
321 if not nxt and st.route:
322 nxt = next(iter(st.route.routes.values()), "") or st.route.default or ""
323 if nxt:
324 main_edge_set.add((current, nxt))
325 current = nxt
326 else:
327 break
328 return main_path, main_edge_set
331def _classify_edges(
332 edges: list[tuple[str, str, str]],
333 main_edge_set: set[tuple[str, str]],
334 bfs_order: list[str],
335) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
336 """Split non-main edges into branches and back_edges."""
337 main_consumed: set[int] = set()
338 for src, dst in main_edge_set:
339 for i, (s, d, _) in enumerate(edges):
340 if s == src and d == dst and i not in main_consumed:
341 main_consumed.add(i)
342 break
344 branches: list[tuple[str, str, str]] = []
345 back_edges: list[tuple[str, str, str]] = []
346 for i, (src, dst, label) in enumerate(edges):
347 if i in main_consumed:
348 continue
349 src_pos = bfs_order.index(src) if src in bfs_order else len(bfs_order)
350 dst_pos = bfs_order.index(dst) if dst in bfs_order else len(bfs_order)
351 if dst == src or dst_pos < src_pos:
352 back_edges.append((src, dst, label))
353 else:
354 branches.append((src, dst, label))
355 return branches, back_edges
358class TopologyDetector:
359 """Classify FSM graph topology for layout strategy selection."""
361 def __init__(
362 self,
363 edges: list[tuple[str, str, str]],
364 main_path: list[str],
365 branches: list[tuple[str, str, str]],
366 back_edges: list[tuple[str, str, str]],
367 ) -> None:
368 self._edges = edges
369 self._main_path = main_path
370 self._branches = branches
371 self._back_edges = back_edges
373 def classify(self) -> str:
374 """Return 'linear', 'tree', or 'general'.
376 Linear: main path only, no non-self branches, only self-loop back-edges.
377 Tree: branches exist but no fan-in (every non-initial state has ≤1 incoming).
378 General: everything else (full Sugiyama needed).
379 """
380 non_self_branches = [b for b in self._branches if b[0] != b[1]]
381 non_self_back = [b for b in self._back_edges if b[0] != b[1]]
383 if not non_self_branches and not non_self_back:
384 return "linear"
386 # Check for fan-in: any state with >1 incoming forward edge
387 in_count: dict[str, int] = {}
388 for _, dst, _ in self._edges:
389 # Only count forward edges (not back-edges)
390 in_count[dst] = in_count.get(dst, 0) + 1
392 if not non_self_back and all(v <= 1 for v in in_count.values()):
393 return "tree"
395 return "general"
398# ---------------------------------------------------------------------------
399# Sugiyama layout pipeline
400# ---------------------------------------------------------------------------
403class LayerAssigner:
404 """Assign nodes to layers using longest-path + width constraint."""
406 def __init__(
407 self,
408 all_states: list[str],
409 edges: list[tuple[str, str, str]],
410 back_edge_set: set[tuple[str, str]],
411 initial: str,
412 max_width: int = 4,
413 ) -> None:
414 self._all_states = all_states
415 self._edges = edges
416 self._back_edge_set = back_edge_set
417 self._initial = initial
418 self._max_width = max_width
420 def assign(self) -> list[list[str]]:
421 """Return list of layers, each a list of state names (top to bottom)."""
422 # Build adjacency (forward edges only)
423 forward: dict[str, list[str]] = {s: [] for s in self._all_states}
424 reverse: dict[str, list[str]] = {s: [] for s in self._all_states}
425 seen_edges: set[tuple[str, str]] = set()
426 for src, dst, _ in self._edges:
427 if (src, dst) in self._back_edge_set or src == dst:
428 continue
429 if src in forward and dst in forward and (src, dst) not in seen_edges:
430 forward[src].append(dst)
431 reverse[dst].append(src)
432 seen_edges.add((src, dst))
434 # Longest-path layer assignment (topological order)
435 layer_of: dict[str, int] = {}
437 # Kahn's algorithm for topological order
438 in_degree = {s: len(reverse[s]) for s in self._all_states}
439 queue: deque[str] = deque()
440 for s in self._all_states:
441 if in_degree[s] == 0:
442 queue.append(s)
444 topo_order: list[str] = []
445 while queue:
446 node = queue.popleft()
447 topo_order.append(node)
448 for dst in forward[node]:
449 in_degree[dst] -= 1
450 if in_degree[dst] == 0:
451 queue.append(dst)
453 # Handle nodes not reached by topo sort (cycles in forward graph)
454 for s in self._all_states:
455 if s not in set(topo_order):
456 topo_order.append(s)
458 # Assign layers: longest path from root
459 for node in topo_order:
460 if not reverse[node]:
461 layer_of[node] = 0
462 else:
463 layer_of[node] = max(
464 (layer_of.get(p, 0) + 1 for p in reverse[node]),
465 default=0,
466 )
468 # Ensure initial state is at layer 0
469 if self._initial in layer_of and layer_of[self._initial] != 0:
470 offset = layer_of[self._initial]
471 for s in layer_of:
472 layer_of[s] -= offset
474 # Build layers list
475 max_layer = max(layer_of.values(), default=0)
476 layers: list[list[str]] = [[] for _ in range(max_layer + 1)]
477 for s in self._all_states:
478 layer = layer_of.get(s, 0)
479 layers[layer].append(s)
481 # Width constraint: if any layer exceeds max_width, split
482 if self._max_width > 0:
483 new_layers: list[list[str]] = []
484 for grp in layers:
485 remaining = list(grp)
486 while len(remaining) > self._max_width:
487 new_layers.append(remaining[: self._max_width])
488 remaining = remaining[self._max_width :]
489 if remaining:
490 new_layers.append(remaining)
491 layers = new_layers
493 return layers
496class CrossingMinimizer:
497 """Minimize edge crossings using barycenter heuristic."""
499 def __init__(
500 self,
501 layers: list[list[str]],
502 edges: list[tuple[str, str, str]],
503 back_edge_set: set[tuple[str, str]],
504 ) -> None:
505 self._layers = layers
506 self._edges = edges
507 self._back_edge_set = back_edge_set
509 def minimize(self) -> list[list[str]]:
510 """Return reordered layers with reduced crossings."""
511 # Build position lookup
512 layer_of: dict[str, int] = {}
513 for i, layer in enumerate(self._layers):
514 for s in layer:
515 layer_of[s] = i
517 # Forward adjacency (non-back, non-self)
518 adj_down: dict[str, list[str]] = {}
519 adj_up: dict[str, list[str]] = {}
520 for src, dst, _ in self._edges:
521 if (src, dst) in self._back_edge_set or src == dst:
522 continue
523 if src in layer_of and dst in layer_of:
524 adj_down.setdefault(src, []).append(dst)
525 adj_up.setdefault(dst, []).append(src)
527 layers = [list(layer) for layer in self._layers]
529 # 3 sweeps: down, up, down
530 for sweep in range(3):
531 if sweep % 2 == 0:
532 # Top-down sweep
533 for i in range(1, len(layers)):
534 pos_above = {s: j for j, s in enumerate(layers[i - 1])}
535 bary: dict[str, float] = {}
536 for s in layers[i]:
537 parents = [p for p in adj_up.get(s, []) if p in pos_above]
538 if parents:
539 bary[s] = sum(pos_above[p] for p in parents) / len(parents)
540 else:
541 bary[s] = float(layers[i].index(s))
542 layers[i].sort(key=lambda s: bary.get(s, 0))
543 else:
544 # Bottom-up sweep
545 for i in range(len(layers) - 2, -1, -1):
546 pos_below = {s: j for j, s in enumerate(layers[i + 1])}
547 bary_up: dict[str, float] = {}
548 for s in layers[i]:
549 children = [c for c in adj_down.get(s, []) if c in pos_below]
550 if children:
551 bary_up[s] = sum(pos_below[c] for c in children) / len(children)
552 else:
553 bary_up[s] = float(layers[i].index(s))
554 layers[i].sort(key=lambda s: bary_up.get(s, 0))
556 return layers
559# ---------------------------------------------------------------------------
560# Rendering helpers
561# ---------------------------------------------------------------------------
564def _compute_display_labels(
565 all_states: list[str],
566 initial: str,
567 terminal_states: set[str],
568) -> dict[str, str]:
569 """Compute display labels with → prefix and ◉ suffix."""
570 display_label: dict[str, str] = {}
571 for s in all_states:
572 label = s
573 if s == initial:
574 label = "\u2192 " + label
575 if s in terminal_states:
576 label = label + " \u25c9"
577 display_label[s] = label
578 return display_label
581def _compute_box_sizes(
582 all_states: list[str],
583 display_label: dict[str, str],
584 fsm_states: dict[str, StateConfig] | None,
585 verbose: bool,
586 max_box_inner: int,
587 badges: dict[str, str] | None = None,
588 title_only: bool = False,
589) -> tuple[dict[str, list[str]], dict[str, int], dict[str, int], dict[str, str]]:
590 """Compute box content, widths, and heights for all states.
592 Returns (box_inner, box_width, box_height, box_badge).
594 When ``title_only`` is True, action body lines are suppressed (used by
595 ``--show-diagrams=mini`` for skeleton rendering); box widths are computed
596 from the name label / badge only.
597 """
598 box_inner: dict[str, list[str]] = {}
599 box_width: dict[str, int] = {}
600 box_badge: dict[str, str] = {}
602 for s in all_states:
603 state_obj = fsm_states.get(s) if fsm_states else None
605 badge = _get_state_badge(state_obj, badges)
606 badge_w = _badge_display_width(badge) if badge else 0
607 box_badge[s] = badge
609 # Width must fit: name label on content row, badge on top border (with one space
610 # of padding on each side: " badge "). All widths are display columns.
611 base_w = max(_display_width(display_label[s]), badge_w + 2 if badge_w else 0)
613 inner_w = base_w
614 if not title_only and state_obj and state_obj.action and max_box_inner > 0:
615 action_lines = state_obj.action.strip().splitlines()
616 if verbose:
617 max_action_w = max(
618 (_display_width(ln.rstrip()) for ln in action_lines if ln.rstrip()), default=0
619 )
620 inner_w = max(base_w, min(max_action_w, max_box_inner))
621 else:
622 first_action = next((ln.rstrip() for ln in action_lines if ln.rstrip()), "")
623 inner_w = max(base_w, min(_display_width(first_action), max_box_inner))
625 content = _box_inner_lines(
626 state_obj, display_label[s], verbose, inner_w, title_only=title_only
627 )
628 actual_w = max(_display_width(ln) for ln in content)
629 inner_w = max(inner_w, actual_w)
630 box_inner[s] = content
631 box_width[s] = inner_w + 4 # "│ " + content + " │"
633 box_height: dict[str, int] = {s: len(box_inner[s]) + 2 for s in all_states}
634 return box_inner, box_width, box_height, box_badge
637def _draw_box(
638 grid: list[list[str]],
639 row: int,
640 col: int,
641 width: int,
642 height: int,
643 content: list[str],
644 is_highlighted: bool,
645 highlight_color: str,
646 badge: str = "",
647) -> None:
648 """Draw a state box onto a character grid at (row, col).
650 If *badge* is provided it is placed right-aligned in the top border row with
651 one space of padding on each side (``─ badge ┐``), colorized via ``_bc()``.
652 """
653 total_width = len(grid[0]) if grid else 0
654 try:
655 bg_code: str | None = str(int(highlight_color) + 10)
656 except (ValueError, TypeError):
657 bg_code = None
659 # Pre-compute the combined border SGR code for highlighted boxes so
660 # entire border strings can be batched into a single colorize() call.
661 border_code: str | None = None
662 if is_highlighted:
663 border_code = f"{highlight_color};{bg_code}" if bg_code else highlight_color
665 def _bc(ch: str) -> str:
666 """Colorize a single character for non-batched cell writes (side borders)."""
667 if not is_highlighted:
668 return ch
669 if bg_code:
670 return colorize(ch, f"{highlight_color};{bg_code}")
671 return colorize(ch, highlight_color)
673 # ── Top border ──────────────────────────────────────────────────────
674 # Batched into a single colorize() call when highlighted. When a badge
675 # is present the full border string is built in one shot — badge display
676 # width drives dash-count arithmetic so wide chars are handled correctly.
677 if is_highlighted and border_code:
678 if not badge:
679 grid[row][col] = colorize("\u250c" + "\u2500" * (width - 2) + "\u2510", border_code)
680 else:
681 badge_w = _badge_display_width(badge)
682 dash_count = width - badge_w - 4
683 grid[row][col] = colorize(
684 "\u250c" + "\u2500" * dash_count + " " + badge + " " + "\u2510",
685 border_code,
686 )
687 # Clear cells consumed by the batched border string
688 for j in range(1, width):
689 if col + j < total_width:
690 grid[row][col + j] = ""
691 else:
692 if col < total_width:
693 grid[row][col] = "\u250c"
694 for j in range(1, width - 1):
695 if col + j < total_width:
696 grid[row][col + j] = "\u2500"
697 if col + width - 1 < total_width:
698 grid[row][col + width - 1] = "\u2510"
700 # Overlay badge in top-right corner (non-highlighted path only;
701 # highlighted path builds the badge into the batched string above).
702 if badge:
703 badge_w = _badge_display_width(badge)
704 trail_pos = col + width - 2
705 if col + 1 <= trail_pos < col + width - 1 and trail_pos < total_width:
706 grid[row][trail_pos] = " "
707 lead_pos = col + width - badge_w - 3
708 if col + 1 <= lead_pos < col + width - 1 and lead_pos < total_width:
709 grid[row][lead_pos] = " "
710 pos = col + width - 1 - badge_w - 1
711 for ch in badge:
712 ch_w = _wcwidth(ch)
713 if ch_w < 1:
714 ch_w = 1
715 if col + 1 <= pos < col + width - 1 and pos < total_width:
716 grid[row][pos] = ch
717 if ch_w == 2 and pos + 1 < col + width - 1 and pos + 1 < total_width:
718 grid[row][pos + 1] = ""
719 pos += ch_w
721 # ── Interior fill ───────────────────────────────────────────────────
722 # Batched per row — a single colorize(" " * N, bg_code) replaces the
723 # per-cell loop that previously generated (width-2) SGR pairs per row.
724 if is_highlighted and bg_code:
725 fill_str = colorize(" " * (width - 2), bg_code)
726 for ri in range(row + 1, row + height - 1):
727 if ri >= len(grid):
728 break
729 if col + 1 < total_width:
730 grid[ri][col + 1] = fill_str
732 # ── Content rows ────────────────────────────────────────────────────
733 def _place_content_row(
734 r: int,
735 line: str,
736 lead_code: str | None,
737 text_code: str | None,
738 fill_code: str | None,
739 ) -> None:
740 """Lay one interior line into the grid: leading space, the line itself,
741 then trailing fill up to the right border.
743 The line (and the trailing-fill run) are each written into a *single*
744 grid cell as a multi-character string for SGR batching, so the grid
745 cells those strings visually cover MUST be cleared — otherwise their
746 original single-space contents survive ``"".join(row)`` and push the
747 right border out (the action-row overflow bug). Widths are display
748 columns so wide glyphs are accounted for.
749 """
750 dw = _display_width(line)
751 if col + 1 < total_width:
752 grid[r][col + 1] = colorize(" ", lead_code) if lead_code else " "
753 if col + 2 < total_width:
754 grid[r][col + 2] = colorize(line, text_code) if text_code else line
755 # Clear the cells the content string visually covers.
756 for j in range(1, dw):
757 cc = col + 2 + j
758 if cc < col + width - 1 and cc < total_width:
759 grid[r][cc] = ""
760 # Trailing fill between content and the right border.
761 trail_pad = width - 3 - dw
762 fill_start = col + 2 + dw
763 if trail_pad > 0 and fill_start < total_width:
764 grid[r][fill_start] = (
765 colorize(" " * trail_pad, fill_code) if fill_code else " " * trail_pad
766 )
767 for j in range(1, trail_pad):
768 cc = fill_start + j
769 if cc < col + width - 1 and cc < total_width:
770 grid[r][cc] = ""
772 for i, line in enumerate(content):
773 r = row + i + 1
774 if r >= len(grid):
775 break
776 # Clear the batched fill cell for this row so content placement
777 # rebuilds the interior with proper leading-space + content +
778 # trailing-fill layout (the batched fill at col+1 would otherwise
779 # appear before the content instead of behind it).
780 if is_highlighted and bg_code:
781 grid[r][col + 1] = ""
782 if col < total_width:
783 grid[r][col] = _bc("\u2502")
784 if col + width - 1 < total_width:
785 grid[r][col + width - 1] = _bc("\u2502")
786 if i == 0:
787 # Name row (bold; brightened on the highlighted box).
788 if is_highlighted:
789 lead_code: str | None = bg_code or highlight_color
790 text_code: str | None = f"97;{bg_code};1" if bg_code else f"{highlight_color};1"
791 fill_code: str | None = bg_code or highlight_color
792 else:
793 lead_code = None
794 text_code = "1"
795 fill_code = None
796 else:
797 # Action body rows.
798 if is_highlighted and bg_code:
799 lead_code = bg_code
800 text_code = f"97;{bg_code}"
801 fill_code = bg_code
802 else:
803 lead_code = None
804 text_code = None
805 fill_code = None
806 _place_content_row(r, line, lead_code, text_code, fill_code)
808 # ── Padding rows ────────────────────────────────────────────────────
809 for i in range(len(content) + 1, height - 1):
810 r = row + i
811 if r >= len(grid):
812 break
813 if col < total_width:
814 grid[r][col] = _bc("\u2502")
815 if col + width - 1 < total_width:
816 grid[r][col + width - 1] = _bc("\u2502")
818 # ── Bottom border ───────────────────────────────────────────────────
819 # Fully batched — same pattern as top border (no badge on bottom).
820 brow = row + height - 1
821 if brow < len(grid):
822 if is_highlighted and border_code:
823 if col < total_width:
824 grid[brow][col] = colorize(
825 "\u2514" + "\u2500" * (width - 2) + "\u2518", border_code
826 )
827 for j in range(1, width):
828 if col + j < total_width:
829 grid[brow][col + j] = ""
830 else:
831 if col < total_width:
832 grid[brow][col] = "\u2514"
833 for j in range(1, width - 1):
834 if col + j < total_width:
835 grid[brow][col + j] = "\u2500"
836 if col + width - 1 < total_width:
837 grid[brow][col + width - 1] = "\u2518"
840# ---------------------------------------------------------------------------
841# Layered (vertical) renderer
842# ---------------------------------------------------------------------------
845def _render_layered_diagram(
846 layers: list[list[str]],
847 edges: list[tuple[str, str, str]],
848 main_edge_set: set[tuple[str, str]],
849 branches: list[tuple[str, str, str]],
850 back_edges: list[tuple[str, str, str]],
851 initial: str,
852 terminal_states: set[str] | None,
853 fsm_states: dict[str, StateConfig] | None,
854 verbose: bool,
855 highlight_state: str | None,
856 highlight_color: str,
857 edge_label_colors: dict[str, str] | None = None,
858 badges: dict[str, str] | None = None,
859 title_only: bool = False,
860 suppress_labels: bool = False,
861) -> str:
862 """Render FSM using layered (Sugiyama-style) vertical layout.
864 When ``title_only`` is True, per-state body lines are suppressed.
865 When ``suppress_labels`` is True, inter-state edges render without labels.
866 """
867 terminal_states = terminal_states or set()
868 fsm_states = fsm_states or {}
869 tw = terminal_width()
871 # Flatten layers to get all states
872 all_states = [s for layer in layers for s in layer]
873 if not all_states:
874 return ""
876 display_label = _compute_display_labels(all_states, initial, terminal_states)
878 # Compute max_box_inner based on widest layer
879 max_layer_size = max(len(layer) for layer in layers)
880 if verbose:
881 max_box_inner = max(20, min(60, (tw - 4) // max(1, max_layer_size) - 6))
882 else:
883 max_box_inner = max(20, min(40, (tw - 4) // max(1, max_layer_size) - 6))
885 box_inner, box_width, box_height, box_badge = _compute_box_sizes(
886 all_states,
887 display_label,
888 fsm_states,
889 verbose,
890 max_box_inner,
891 badges,
892 title_only=title_only,
893 )
895 # Post-hoc layer merge: re-merge adjacent single-state layers that were
896 # over-split by the conservative max_width_per_layer estimate. Only merge
897 # when both layers receive an edge from the same source state (indicating
898 # they were originally one layer split by width constraint).
899 available_w = tw - 20 # conservative content-area estimate
900 gap_between = 6
901 # Build edge target sets: for each state, which earlier states point to it
902 _incoming: dict[str, set[str]] = {s: set() for layer in layers for s in layer}
903 for src, dst, _ in edges:
904 if src != dst and dst in _incoming:
905 _incoming[dst].add(src)
906 merged = True
907 while merged:
908 merged = False
909 i = 0
910 while i < len(layers) - 1:
911 la, lb = layers[i], layers[i + 1]
912 # Only merge single-state layers that share an incoming source
913 if len(la) == 1 and len(lb) == 1:
914 sources_a = _incoming.get(la[0], set())
915 sources_b = _incoming.get(lb[0], set())
916 shared_source = sources_a & sources_b
917 else:
918 shared_source = set()
919 combined_w = (
920 sum(box_width[s] for s in la)
921 + gap_between * (len(la) - 1)
922 + gap_between
923 + sum(box_width[s] for s in lb)
924 + gap_between * (len(lb) - 1)
925 )
926 if shared_source and combined_w <= available_w and len(la) + len(lb) <= 4:
927 layers[i] = la + lb
928 layers.pop(i + 1)
929 merged = True
930 else:
931 i += 1
933 # Collect ALL non-self-loop forward edge labels (main + branches + same-depth back-edges)
934 # Multiple edges between the same pair are combined as "label1/label2"
935 forward_edge_labels: dict[tuple[str, str], str] = {}
936 for src, dst, lbl in edges:
937 if src == dst:
938 continue
939 if (src, dst) in main_edge_set or (src, dst, lbl) in branches:
940 if (src, dst) in forward_edge_labels:
941 forward_edge_labels[(src, dst)] += "/" + lbl
942 else:
943 forward_edge_labels[(src, dst)] = lbl
945 # True back-edges: only those going to an earlier layer (computed after layer assignment)
946 # Will be finalized below after col positions are computed
947 # Combine same-pair back-edges into single entries with merged labels (e.g. "error/fail")
948 back_edge_labels_initial: dict[tuple[str, str], str] = {}
949 for s, d, lbl in back_edges:
950 if s != d:
951 if (s, d) in back_edge_labels_initial:
952 back_edge_labels_initial[(s, d)] += "/" + lbl
953 else:
954 back_edge_labels_initial[(s, d)] = lbl
956 # Pre-compute layer positions to detect main-path cycle edges early.
957 # This ensures back_edge_margin accounts for ALL backward-pointing edges
958 # (including main-path cycles like commit → initial_state) before column
959 # positions are computed.
960 prelim_layer_of: dict[str, int] = {}
961 for li, layer in enumerate(layers):
962 for s in layer:
963 prelim_layer_of[s] = li
965 # Include main-path/branch edges that point backward in margin estimate
966 all_back_labels: dict[tuple[str, str], str] = dict(back_edge_labels_initial)
967 for (src, dst), lbl in forward_edge_labels.items():
968 src_layer = prelim_layer_of.get(src, -1)
969 dst_layer = prelim_layer_of.get(dst, -1)
970 if dst_layer < src_layer:
971 if (src, dst) in all_back_labels:
972 all_back_labels[(src, dst)] += "/" + lbl
973 else:
974 all_back_labels[(src, dst)] = lbl
976 non_self_back_initial = [(s, d, lbl) for (s, d), lbl in all_back_labels.items()]
977 back_edge_margin = 0
978 if non_self_back_initial:
979 max_label_len = max(len(lbl) for _, _, lbl in non_self_back_initial)
980 n_back_initial = len(non_self_back_initial)
981 back_edge_margin = max_label_len + max(6, 2 * n_back_initial + 2)
983 content_left = 2 + back_edge_margin
985 # Self-loops per state
986 self_loops: dict[str, list[str]] = {}
987 for src, dst, lbl in back_edges:
988 if src == dst:
989 self_loops.setdefault(src, []).append(lbl)
991 # --- Compute a common center column for alignment ---
992 # For layers with single boxes, we want vertical alignment through a
993 # shared center column. Use the widest single-state layer's center.
994 max_single_w = max((box_width[layer[0]] for layer in layers if len(layer) == 1), default=0)
995 # The common center is at content_left + max_single_w // 2
996 common_center = content_left + max_single_w // 2
998 # Compute column positions per layer
999 col_start: dict[str, int] = {}
1000 col_center: dict[str, int] = {}
1001 layer_of: dict[str, int] = {}
1003 for li, layer in enumerate(layers):
1004 if len(layer) == 1:
1005 # Single-state layer: center-align to common center
1006 sname = layer[0]
1007 col_start[sname] = common_center - box_width[sname] // 2
1008 col_center[sname] = common_center
1009 layer_of[sname] = li
1010 else:
1011 # Multi-state layer: place side-by-side, centered around common_center
1012 gap_between = 6
1013 total_layer_w = sum(box_width[s] for s in layer) + gap_between * (len(layer) - 1)
1014 x = common_center - total_layer_w // 2
1015 x = max(content_left, x)
1016 for i, sname in enumerate(layer):
1017 col_start[sname] = x
1018 col_center[sname] = x + box_width[sname] // 2
1019 layer_of[sname] = li
1020 if i < len(layer) - 1:
1021 next_s = layer[i + 1]
1022 # Check for edge labels in both directions between adjacent states
1023 label_fwd = forward_edge_labels.get((sname, next_s), "")
1024 label_rev = forward_edge_labels.get((next_s, sname), "")
1025 max_label = max(len(label_fwd), len(label_rev))
1026 gap = max(gap_between, max_label + 6) if max_label > 0 else gap_between
1027 x += box_width[sname] + gap
1028 else:
1029 x += box_width[sname]
1031 # Reclassify back-edges based on actual layer positions
1032 # Only edges going to an earlier layer are true margin back-edges
1033 # Combine same-pair edges into merged labels (e.g. "error/fail")
1034 back_edge_labels_reclass: dict[tuple[str, str], str] = {}
1035 same_layer_edges: list[tuple[str, str, str]] = []
1036 for src, dst, lbl in back_edges:
1037 if src == dst:
1038 continue
1039 src_layer = layer_of.get(src, -1)
1040 dst_layer = layer_of.get(dst, -1)
1041 if dst_layer < src_layer:
1042 if (src, dst) in back_edge_labels_reclass:
1043 back_edge_labels_reclass[(src, dst)] += "/" + lbl
1044 else:
1045 back_edge_labels_reclass[(src, dst)] = lbl
1046 elif dst_layer == src_layer:
1047 same_layer_edges.append((src, dst, lbl))
1048 else: # dst_layer > src_layer: actually forward edge
1049 if (src, dst) in forward_edge_labels:
1050 forward_edge_labels[(src, dst)] += "/" + lbl
1051 else:
1052 forward_edge_labels[(src, dst)] = lbl
1054 # Also reclassify main/branch edges in forward_edge_labels that point backward
1055 # after layer assignment (e.g. main-path cycle edges like commit → initial_state)
1056 backward_in_fwd: list[tuple[str, str]] = []
1057 for (src, dst), lbl in forward_edge_labels.items():
1058 src_layer = layer_of.get(src, -1)
1059 dst_layer = layer_of.get(dst, -1)
1060 if dst_layer < src_layer:
1061 backward_in_fwd.append((src, dst))
1062 if (src, dst) in back_edge_labels_reclass:
1063 back_edge_labels_reclass[(src, dst)] += "/" + lbl
1064 else:
1065 back_edge_labels_reclass[(src, dst)] = lbl
1066 elif dst_layer == src_layer and src != dst:
1067 backward_in_fwd.append((src, dst))
1068 same_layer_edges.append((src, dst, lbl))
1069 for key in backward_in_fwd:
1070 del forward_edge_labels[key]
1072 # Add same-layer back-edges to forward_edge_labels so gap calculation accounts for them
1073 for src, dst, lbl in same_layer_edges:
1074 if (src, dst) in forward_edge_labels:
1075 forward_edge_labels[(src, dst)] += "/" + lbl
1076 else:
1077 forward_edge_labels[(src, dst)] = lbl
1079 # Recalculate inter-box gaps for layers with newly discovered same-layer edges
1080 affected_layers: set[int] = set()
1081 for src, dst, _lbl in same_layer_edges:
1082 sl = layer_of.get(src, -1)
1083 dl = layer_of.get(dst, -1)
1084 if sl >= 0:
1085 affected_layers.add(sl)
1086 if dl >= 0:
1087 affected_layers.add(dl)
1088 for li in affected_layers:
1089 layer = layers[li]
1090 if len(layer) < 2:
1091 continue
1092 gap_between = 6
1093 total_layer_w = sum(box_width[s] for s in layer)
1094 # For non-adjacent same-layer edges the label lands in the gap immediately
1095 # adjacent to the source box (left of src for right-to-left, right of src
1096 # for left-to-right). Collect those requirements so the gap is wide enough.
1097 extra_gap_req: dict[tuple[str, str], int] = {}
1098 for src, dst, lbl in same_layer_edges:
1099 if layer_of.get(src) != li or layer_of.get(dst) != li:
1100 continue
1101 try:
1102 si, di = layer.index(src), layer.index(dst)
1103 except ValueError:
1104 continue
1105 if abs(si - di) <= 1:
1106 continue # adjacent — already handled by forward_edge_labels
1107 if si > di:
1108 key = (layer[si - 1], src) # gap to the left of src
1109 else:
1110 key = (src, layer[si + 1]) # gap to the right of src
1111 extra_gap_req[key] = max(extra_gap_req.get(key, 0), len(lbl))
1112 # Recalculate gaps with label-aware spacing
1113 gaps: list[int] = []
1114 for i in range(len(layer) - 1):
1115 sname, next_s = layer[i], layer[i + 1]
1116 label_fwd = forward_edge_labels.get((sname, next_s), "")
1117 label_rev = forward_edge_labels.get((next_s, sname), "")
1118 max_label = max(len(label_fwd), len(label_rev), extra_gap_req.get((sname, next_s), 0))
1119 gap = max(gap_between, max_label + 6) if max_label > 0 else gap_between
1120 gaps.append(gap)
1121 total_layer_w += sum(gaps)
1122 x = common_center - total_layer_w // 2
1123 x = max(content_left, x)
1124 for i, sname in enumerate(layer):
1125 col_start[sname] = x
1126 col_center[sname] = x + box_width[sname] // 2
1127 if i < len(layer) - 1:
1128 x += box_width[sname] + gaps[i]
1129 else:
1130 x += box_width[sname]
1132 non_self_back = [(s, d, lbl) for (s, d), lbl in back_edge_labels_reclass.items()]
1134 # Recalculate back-edge margin if it changed
1135 if non_self_back:
1136 max_label_len = max(len(lbl) for _, _, lbl in non_self_back)
1137 n_back = len(non_self_back)
1138 actual_margin = max_label_len + max(6, 2 * n_back + 2)
1139 if actual_margin != back_edge_margin:
1140 # Need to recalculate positions (rare case - usually matches)
1141 back_edge_margin = actual_margin
1142 content_left = 2 + back_edge_margin
1144 # Identify forward skip-layer edges (span > 1 layer, not handled by consecutive renderer)
1145 skip_forward_edges: list[tuple[str, str, str]] = []
1146 for (src, dst), lbl in forward_edge_labels.items():
1147 src_layer = layer_of.get(src, -1)
1148 dst_layer = layer_of.get(dst, -1)
1149 if dst_layer > src_layer + 1:
1150 skip_forward_edges.append((src, dst, lbl))
1152 # Pre-compute right margin width for forward skip-layer edges
1153 right_edge_margin = 0
1154 if skip_forward_edges:
1155 max_fwd_label_len = max(len(lbl) for _, _, lbl in skip_forward_edges)
1156 right_edge_margin = max_fwd_label_len + 6
1158 # Compute total width needed
1159 total_content_w = 0
1160 for s in all_states:
1161 right = col_start[s] + box_width[s]
1162 total_content_w = max(total_content_w, right)
1163 total_width = max(total_content_w + right_edge_margin + 4, tw)
1165 # Compute vertical positions with space for self-loops and inter-layer arrows
1166 row_start: dict[str, int] = {}
1167 y = 0
1168 for li, layer in enumerate(layers):
1169 layer_h = max(box_height[s] for s in layer)
1170 for sname in layer:
1171 row_start[sname] = y
1172 y += layer_h
1174 # Add self-loop row if any state in this layer has self-loops
1175 has_self_loop = any(s in self_loops for s in layer)
1176 if has_self_loop:
1177 y += 1 # self-loop marker row
1179 if li < len(layers) - 1:
1180 y += 2 if suppress_labels else 3 # arrow gap: suppress_labels skips label row
1182 total_height = y
1184 # Build character grid
1185 grid: list[list[str]] = [[" "] * total_width for _ in range(total_height)]
1187 # Draw boxes
1188 for sname in all_states:
1189 is_highlighted = highlight_state is not None and sname == highlight_state
1190 _draw_box(
1191 grid,
1192 row_start[sname],
1193 col_start[sname],
1194 box_width[sname],
1195 box_height[sname],
1196 box_inner[sname],
1197 is_highlighted,
1198 highlight_color,
1199 badge=box_badge[sname],
1200 )
1202 # Precompute box-occupied (row, col) pairs so connector lines can avoid overwriting box cells
1203 _box_occ: dict[int, set[int]] = {}
1204 for _s in all_states:
1205 for _r in range(row_start[_s], row_start[_s] + box_height[_s]):
1206 _row_set = _box_occ.setdefault(_r, set())
1207 for _c in range(col_start[_s], col_start[_s] + box_width[_s]):
1208 _row_set.add(_c)
1210 # Draw self-loop markers immediately below their boxes
1211 for sname, labels in self_loops.items():
1212 marker = "\u21ba" if suppress_labels else "\u21ba " + ", ".join(labels)
1213 r = row_start[sname] + box_height[sname]
1214 if r < total_height:
1215 cx = col_center[sname]
1216 pos = max(0, cx - len(marker) // 2)
1217 for j, ch in enumerate(marker):
1218 if pos + j < total_width:
1219 grid[r][pos + j] = ch
1221 # Shared row tracker: prevents two labels (back-edge, skip-forward, or adjacent)
1222 # landing on the same grid row, which would clobber the first label written there.
1223 used_label_rows: set[int] = set()
1225 # Draw forward edges between layers (vertical arrows with labels)
1226 for li in range(len(layers) - 1):
1227 layer_h = max(box_height[s] for s in layers[li])
1228 has_self_loop = any(s in self_loops for s in layers[li])
1229 self_loop_offset = 1 if has_self_loop else 0
1231 # Arrow area starts after box bottom + self-loop row
1232 arrow_start_row = row_start[layers[li][0]] + layer_h + self_loop_offset
1233 arrow_end_row = row_start[layers[li + 1][0]] - 1
1235 # Collect all inter-layer edges from this layer to the next
1236 inter_edges: list[tuple[str, str, str]] = []
1237 for src in layers[li]:
1238 for dst in layers[li + 1]:
1239 label = forward_edge_labels.get((src, dst))
1240 if label is not None:
1241 inter_edges.append((src, dst, label))
1243 # Draw each edge with its own vertical pipe to the target's center
1244 for src, dst, label in inter_edges:
1245 dst_cc = col_center[dst]
1246 src_left = col_start[src]
1247 src_right = src_left + box_width[src]
1248 ec = _edge_line_color(label) # ANSI code for this edge's connector chars
1250 def _lc(ch: str, _ec: str = ec) -> str: # noqa: E306
1251 return colorize(ch, _ec) if _ec else ch
1253 # Horizontal connector when pipe is outside source box range
1254 if dst_cc >= src_right or dst_cc < src_left:
1255 conn_row = arrow_start_row
1256 if 0 <= conn_row < total_height:
1257 if dst_cc >= src_right:
1258 # Pipe right of source: └───┐
1259 src_cc = col_center[src]
1260 if 0 <= src_cc < total_width and grid[conn_row][src_cc] == " ":
1261 grid[conn_row][src_cc] = _lc("\u2514") # └
1262 start_c = src_cc + 1
1263 else:
1264 start_c = src_right
1265 for c in range(start_c, dst_cc):
1266 if 0 <= c < total_width:
1267 grid[conn_row][c] = _lc("\u2500")
1268 if 0 <= dst_cc < total_width:
1269 grid[conn_row][dst_cc] = _lc("\u2510") # ┐
1270 else:
1271 # Pipe left of source: ┌───┘
1272 src_cc = col_center[src]
1273 if 0 <= src_cc < total_width and grid[conn_row][src_cc] == " ":
1274 end_c = src_cc
1275 grid[conn_row][src_cc] = _lc("\u2518") # ┘
1276 else:
1277 end_c = src_left
1278 for c in range(dst_cc + 1, end_c):
1279 if 0 <= c < total_width:
1280 grid[conn_row][c] = _lc("\u2500")
1281 if 0 <= dst_cc < total_width:
1282 grid[conn_row][dst_cc] = _lc("\u250c") # ┌
1283 pipe_start = arrow_start_row + 1
1284 else:
1285 pipe_start = arrow_start_row
1287 # Draw vertical pipe at destination's center column
1288 for r in range(pipe_start, arrow_end_row):
1289 if 0 <= dst_cc < total_width and r < total_height:
1290 grid[r][dst_cc] = _lc("\u2502")
1292 # Arrow tip at destination center
1293 if arrow_end_row < total_height and 0 <= dst_cc < total_width:
1294 grid[arrow_end_row][dst_cc] = _lc("\u25bc")
1296 # Label to the right of the pipe. When skip-layer forward edges
1297 # exist their vertical pipes occupy columns starting at
1298 # total_content_w+2, so clamp to total_content_w to avoid
1299 # overwriting them (BUG-1500). Without skip-layer edges the right
1300 # margin is empty and the full total_width is available.
1301 label_row = arrow_start_row
1302 # Nudge if this row already has a label from another inter-layer edge
1303 # (e.g., two edges from the same source go to states in the same layer).
1304 # Try pipe rows (pipe_start..arrow_end_row) before giving up (BUG-1501).
1305 if label_row in used_label_rows:
1306 for _cand in range(pipe_start, arrow_end_row + 1):
1307 if _cand not in used_label_rows:
1308 label_row = _cand
1309 break
1310 if label_row < total_height and not suppress_labels:
1311 used_label_rows.add(label_row)
1312 label_start = dst_cc + 2
1313 max_col = total_content_w if skip_forward_edges else total_width
1314 max_label = max_col - label_start
1315 if 0 < max_label < len(label):
1316 label = label[: max_label - 1] + "…"
1317 for j, ch in enumerate(label):
1318 if label_start + j < max_col:
1319 grid[label_row][label_start + j] = ch
1321 # Post-pass: connect horizontal gaps for multi-branch sources
1322 if len(inter_edges) >= 2 and 0 <= arrow_start_row < total_height:
1323 src_targets: dict[str, list[int]] = {}
1324 for src, dst, _ in inter_edges:
1325 if dst in col_center:
1326 src_targets.setdefault(src, []).append(col_center[dst])
1327 for _src, centers in src_targets.items():
1328 if len(centers) < 2:
1329 continue
1330 left = min(centers)
1331 right = max(centers)
1332 for c in range(left + 1, right):
1333 if 0 <= c < total_width:
1334 cell = grid[arrow_start_row][c]
1335 if cell == " ":
1336 grid[arrow_start_row][c] = "\u2500" # ─
1337 elif cell == "\u2502": # │ → ┼
1338 grid[arrow_start_row][c] = "\u253c"
1339 elif cell == "\u2518": # ┘ → ┴
1340 grid[arrow_start_row][c] = "\u2534"
1341 elif cell == "\u2514": # └ → ┴
1342 grid[arrow_start_row][c] = "\u2534"
1343 elif cell == "\u2510": # ┐ → ┬
1344 grid[arrow_start_row][c] = "\u252c"
1345 elif cell == "\u250c": # ┌ → ┬
1346 grid[arrow_start_row][c] = "\u252c"
1347 # Update boundary junction chars where the horizontal bar meets a pipe
1348 if 0 <= left < total_width and grid[arrow_start_row][left] == "\u2502": # │ → ├
1349 grid[arrow_start_row][left] = "\u251c"
1350 if 0 <= right < total_width and grid[arrow_start_row][right] == "\u2502": # │ → ┤
1351 grid[arrow_start_row][right] = "\u2524"
1353 # Draw same-layer edges (horizontal arrows between states on same layer)
1354 # Includes both branches and reclassified back-edges within same layer
1355 all_same_layer: list[tuple[str, str, str]] = list(same_layer_edges)
1356 for _li, layer in enumerate(layers):
1357 for i, src in enumerate(layer):
1358 for j, dst in enumerate(layer):
1359 if i == j:
1360 continue
1361 label = forward_edge_labels.get((src, dst))
1362 if label is not None and (src, dst, label) not in all_same_layer:
1363 all_same_layer.append((src, dst, label))
1365 for src, dst, label in all_same_layer:
1366 if src not in col_start or dst not in col_start:
1367 continue
1368 if suppress_labels:
1369 label = ""
1370 name_row = row_start[src] + 1
1371 src_right = col_start[src] + box_width[src]
1372 dst_right = col_start[dst] + box_width[dst]
1373 dst_left = col_start[dst]
1374 src_left = col_start[src]
1375 _row_boxes = _box_occ.get(name_row, set())
1376 ec = _edge_line_color(label)
1378 def _lc(ch: str, _ec: str = ec) -> str: # noqa: E306
1379 return colorize(ch, _ec) if _ec else ch
1381 if dst_left >= src_right:
1382 # Left to right horizontal arrow: src ──label──▶ dst
1383 start = src_right
1384 end = dst_left
1385 edge_text = "\u2500" + label + "\u2500\u2500\u25b6"
1386 available = end - start
1387 if available < len(edge_text):
1388 edge_text = edge_text[: max(1, available)]
1389 left_dashes = max(0, available - len(edge_text))
1390 for k in range(left_dashes):
1391 pos = start + k
1392 if pos < total_width and name_row < total_height and pos not in _row_boxes:
1393 grid[name_row][pos] = _lc("\u2500")
1394 for k, ch in enumerate(edge_text):
1395 pos = start + left_dashes + k
1396 if (
1397 0 <= pos < end
1398 and pos < total_width
1399 and name_row < total_height
1400 and pos not in _row_boxes
1401 ):
1402 grid[name_row][pos] = _lc(ch)
1403 elif dst_right <= src_left:
1404 # Right to left: dst is left of src: src → dst drawn as dst ◀──label── src
1405 start = dst_right
1406 end = src_left
1407 edge_text = "\u25c4\u2500\u2500" + label + "\u2500"
1408 available = end - start
1409 if available < len(edge_text):
1410 edge_text = edge_text[: max(1, available)]
1411 for k, ch in enumerate(edge_text):
1412 pos = start + k
1413 if (
1414 0 <= pos < end
1415 and pos < total_width
1416 and name_row < total_height
1417 and pos not in _row_boxes
1418 ):
1419 grid[name_row][pos] = _lc(ch)
1420 for k in range(start + len(edge_text), end):
1421 if k < total_width and name_row < total_height and k not in _row_boxes:
1422 grid[name_row][k] = _lc("\u2500")
1424 # Back-edges: left-margin vertical arrows with labels
1425 if non_self_back:
1426 sorted_back = sorted(
1427 non_self_back,
1428 key=lambda e: abs(row_start.get(e[0], 0) - row_start.get(e[1], 0)),
1429 reverse=True,
1430 )
1431 used_cols: list[int] = []
1432 # Compute rightmost pipe column so labels go right of ALL pipes
1433 rightmost_pipe_col = 1 + (len(sorted_back) - 1) * 2
1435 for src, dst, label in sorted_back:
1436 # Source: name row of source box; target: name row of target box
1437 src_row = row_start.get(src, 0) + 1 # name row, not bottom border
1438 dst_row = row_start.get(dst, 0) + 1 # name row
1440 # Find a free column in the margin
1441 col = 1
1442 for uc in sorted(used_cols):
1443 if uc == col:
1444 col += 2
1445 used_cols.append(col)
1447 if col >= content_left - 1:
1448 continue
1450 top_row = min(src_row, dst_row)
1451 bot_row = max(src_row, dst_row)
1452 ec = _edge_line_color(label)
1454 def _lc(ch: str, _ec: str = ec) -> str: # noqa: E306
1455 return colorize(ch, _ec) if _ec else ch
1457 # Draw vertical line in margin (exclude corner rows handled below)
1458 for r in range(top_row + 1, bot_row):
1459 if 0 <= r < total_height and col < total_width:
1460 cell = grid[r][col]
1461 if cell == "\u2500": # ─ → ┼ (junction, leave uncolored)
1462 grid[r][col] = "\u253c"
1463 elif cell == " ":
1464 grid[r][col] = _lc("\u2502")
1466 # Horizontal connector from source box to margin
1467 # Draw right-to-left, crossing existing pipes with junction chars
1468 if 0 <= src_row < total_height:
1469 src_left = col_start.get(src, col + 1)
1470 _src_row_boxes = _box_occ.get(src_row, set())
1471 for c in range(col + 1, src_left):
1472 if c < total_width and c not in _src_row_boxes:
1473 cell = grid[src_row][c]
1474 if cell == " ":
1475 grid[src_row][c] = _lc("\u2500") # ─
1476 elif cell == "\u2502": # │ → ┼ (junction)
1477 grid[src_row][c] = "\u253c"
1478 elif cell == "\u2514": # └ → ┴ (junction)
1479 grid[src_row][c] = "\u2534"
1480 elif cell == "\u250c": # ┌ → ┬ (junction)
1481 grid[src_row][c] = "\u252c"
1482 elif cell == "\u251c": # ├ → ┼ (junction)
1483 grid[src_row][c] = "\u253c"
1484 # Leave ─, ▶, box chars unchanged
1486 # Horizontal connector from margin to target box
1487 # Draw right-to-left, crossing existing pipes with junction chars
1488 dst_left = col_start.get(dst, col + 1)
1489 if 0 <= dst_row < total_height:
1490 _dst_row_boxes = _box_occ.get(dst_row, set())
1491 for c in range(col + 1, dst_left):
1492 if c < total_width and c not in _dst_row_boxes:
1493 cell = grid[dst_row][c]
1494 if cell == " ":
1495 grid[dst_row][c] = _lc("\u2500") # ─
1496 elif cell == "\u2502": # │ → ┼ (junction)
1497 grid[dst_row][c] = "\u253c"
1498 elif cell == "\u2514": # └ → ┴ (junction)
1499 grid[dst_row][c] = "\u2534"
1500 elif cell == "\u250c": # ┌ → ┬ (junction)
1501 grid[dst_row][c] = "\u252c"
1502 elif cell == "\u251c": # ├ → ┼ (junction)
1503 grid[dst_row][c] = "\u253c"
1505 # Corner characters at pipe-to-horizontal turn points
1506 for row in (src_row, dst_row):
1507 if 0 <= row < total_height and col < total_width:
1508 existing = grid[row][col]
1509 if row == bot_row:
1510 # Pipe ends, turns right: └; if horizontal already crosses here: ┴
1511 grid[row][col] = "\u2534" if existing == "\u2500" else _lc("\u2514")
1512 else: # row == top_row
1513 # Pipe starts going down, turns right: ┌; if horizontal already crosses here: ┬
1514 grid[row][col] = "\u252c" if existing == "\u2500" else _lc("\u250c")
1516 # Arrow tip at target: place ▶ at end of horizontal connector (entering box from left)
1517 if 0 <= dst_row < total_height and dst_left - 1 > col and dst_left - 1 < total_width:
1518 grid[dst_row][dst_left - 1] = _lc("\u25b6")
1520 # Label on the margin line (right of ALL pipes, not just this one)
1521 label_row_pos = (top_row + bot_row) // 2
1522 # Nudge away from already-used rows to prevent clobbering earlier labels
1523 if label_row_pos in used_label_rows and top_row + 1 < bot_row:
1524 midpoint = label_row_pos
1525 found = False
1526 for _off in range(1, bot_row - top_row):
1527 for _cand in (midpoint - _off, midpoint + _off):
1528 if top_row < _cand < bot_row and _cand not in used_label_rows:
1529 label_row_pos = _cand
1530 found = True
1531 break
1532 if found:
1533 break
1534 if not found:
1535 label_row_pos = top_row + 1
1536 used_label_rows.add(label_row_pos)
1537 if 0 <= label_row_pos < total_height and not title_only:
1538 label_start = rightmost_pipe_col + 2
1539 for j, ch in enumerate(label):
1540 if label_start + j < content_left - 1 and label_start + j < total_width:
1541 grid[label_row_pos][label_start + j] = _lc(ch)
1543 # Forward skip-layer edges: right-margin vertical arrows with labels
1544 # Symmetric to the left-margin back-edge renderer above
1545 if skip_forward_edges:
1546 sorted_fwd_skip = sorted(
1547 skip_forward_edges,
1548 key=lambda e: abs(row_start.get(e[0], 0) - row_start.get(e[1], 0)),
1549 reverse=True,
1550 )
1551 used_fwd_cols: list[int] = []
1552 # Rightmost pipe column (furthest from content) for label placement
1553 rightmost_fwd_pipe_col = total_content_w + 2 + (len(sorted_fwd_skip) - 1) * 2
1555 for src, dst, label in sorted_fwd_skip:
1556 src_row = row_start.get(src, 0) + 1 # name row
1557 dst_row = row_start.get(dst, 0) + 1 # name row
1559 # Allocate column in right margin (starting from content edge, going right)
1560 col = total_content_w + 2
1561 for uc in sorted(used_fwd_cols):
1562 if uc == col:
1563 col += 2
1564 used_fwd_cols.append(col)
1566 if col >= total_width:
1567 continue
1569 top_row = min(src_row, dst_row)
1570 bot_row = max(src_row, dst_row)
1571 ec = _edge_line_color(label)
1573 def _lc(ch: str, _ec: str = ec) -> str: # noqa: E306
1574 return colorize(ch, _ec) if _ec else ch
1576 # Draw vertical line in right margin (exclude corner rows handled below)
1577 for r in range(top_row + 1, bot_row):
1578 if 0 <= r < total_height and col < total_width:
1579 cell = grid[r][col]
1580 if cell == "\u2500": # ─ → ┼ (junction)
1581 grid[r][col] = "\u253c"
1582 elif cell == " ":
1583 grid[r][col] = _lc("\u2502")
1585 # Horizontal connector from source box right side to margin
1586 # Draw left-to-right, crossing existing pipes with junction chars
1587 src_right = col_start.get(src, 0) + box_width.get(src, 0)
1588 _src_row_boxes = _box_occ.get(src_row, set())
1589 if 0 <= src_row < total_height:
1590 for c in range(src_right, col):
1591 if 0 <= c < total_width and c not in _src_row_boxes:
1592 cell = grid[src_row][c]
1593 if cell == " ":
1594 grid[src_row][c] = _lc("\u2500") # ─
1595 elif cell == "\u2502": # │ → ┼ (junction)
1596 grid[src_row][c] = "\u253c"
1597 elif cell == "\u2518": # ┘ → ┴ (junction)
1598 grid[src_row][c] = "\u2534"
1599 elif cell == "\u2510": # ┐ → ┬ (junction)
1600 grid[src_row][c] = "\u252c"
1601 elif cell == "\u2524": # ┤ → ┼ (junction)
1602 grid[src_row][c] = "\u253c"
1603 # Leave ─, ◀, box chars unchanged
1605 # Horizontal connector from margin to destination box right side
1606 dst_right = col_start.get(dst, 0) + box_width.get(dst, 0)
1607 _dst_row_boxes = _box_occ.get(dst_row, set())
1608 if 0 <= dst_row < total_height:
1609 for c in range(dst_right, col):
1610 if 0 <= c < total_width and c not in _dst_row_boxes:
1611 cell = grid[dst_row][c]
1612 if cell == " ":
1613 grid[dst_row][c] = _lc("\u2500") # ─
1614 elif cell == "\u2502": # │ → ┼ (junction)
1615 grid[dst_row][c] = "\u253c"
1616 elif cell == "\u2518": # ┘ → ┴ (junction)
1617 grid[dst_row][c] = "\u2534"
1618 elif cell == "\u2510": # ┐ → ┬ (junction)
1619 grid[dst_row][c] = "\u252c"
1620 elif cell == "\u2524": # ┤ → ┼ (junction)
1621 grid[dst_row][c] = "\u253c"
1623 # Corner characters at pipe-to-horizontal turn points
1624 for row in (src_row, dst_row):
1625 if 0 <= row < total_height and col < total_width:
1626 existing = grid[row][col]
1627 if row == bot_row:
1628 # Pipe ends, turns left: ┘; if horizontal crosses: ┤
1629 grid[row][col] = "\u2524" if existing == "\u2500" else _lc("\u2518")
1630 else: # row == top_row
1631 # Pipe starts going down, turns left: ┐; if horizontal crosses: ┤
1632 grid[row][col] = "\u2524" if existing == "\u2500" else _lc("\u2510")
1634 # Arrow tip at target: ◀ entering box from right side
1635 if 0 <= dst_row < total_height and dst_right < col and dst_right < total_width:
1636 grid[dst_row][dst_right] = _lc("\u25c0")
1638 # Label on the margin line (right of ALL pipes, mirroring left-margin
1639 # approach). Truncate with … when label would exceed total_width
1640 # to prevent extending diagram lines far past the content area (BUG-1500).
1641 label_row_pos = (top_row + bot_row) // 2
1642 # Nudge to avoid row collision with a previously-placed label (back- or fwd-edge)
1643 if label_row_pos in used_label_rows and top_row + 1 < bot_row:
1644 midpoint = label_row_pos
1645 found = False
1646 for _off in range(1, bot_row - top_row):
1647 for _cand in (midpoint - _off, midpoint + _off):
1648 if top_row < _cand < bot_row and _cand not in used_label_rows:
1649 label_row_pos = _cand
1650 found = True
1651 break
1652 if found:
1653 break
1654 if not found:
1655 label_row_pos = top_row + 1
1656 used_label_rows.add(label_row_pos)
1657 if 0 <= label_row_pos < total_height and not title_only:
1658 label_start = rightmost_fwd_pipe_col + 2
1659 max_label = total_width - label_start
1660 if 0 < max_label < len(label):
1661 label = label[: max_label - 1] + "…"
1662 for j, ch in enumerate(label):
1663 if label_start + j < total_width:
1664 grid[label_row_pos][label_start + j] = _lc(ch)
1666 # Convert grid to string
1667 lines = ["".join(row).rstrip() for row in grid]
1669 # Remove trailing empty lines
1670 while lines and not lines[-1].strip():
1671 lines.pop()
1673 # Center diagram
1674 max_line_len = max((_display_width(strip_ansi(ln)) for ln in lines), default=0)
1675 diagram_indent = max(0, (tw - max_line_len) // 2)
1676 if diagram_indent > 0:
1677 lines = [" " * diagram_indent + ln if ln.strip() else ln for ln in lines]
1679 return _colorize_diagram_labels("\n".join(lines), edge_label_colors)
1682# ---------------------------------------------------------------------------
1683# FSM diagram renderer (main entry point)
1684# ---------------------------------------------------------------------------
1687_MAIN_PATH_EDGE_LABELS: frozenset[str] = frozenset({"yes", "no", "next", "_"})
1690def _filter_main_path_graph(
1691 fsm: FSMLoop, edges: list[tuple[str, str, str]]
1692) -> tuple[list[tuple[str, str, str]], set[str]]:
1693 """Filter edges to the main happy-path subgraph and the states reachable through it.
1695 Drops off-happy-path labels (anything not in ``_MAIN_PATH_EDGE_LABELS`` and not
1696 a ``route``-verdict label) plus any state unreachable from ``fsm.initial`` once
1697 those edges are gone. Route-verdict labels (everything emitted by ``state.route``
1698 other than the default ``_``) are kept since routes encode normal branching.
1699 """
1700 route_verdicts: set[str] = set()
1701 for _name, state in fsm.states.items():
1702 if state.route is not None:
1703 route_verdicts.update(state.route.routes.keys())
1704 route_verdicts.update(state.extra_routes.keys())
1706 def _is_main_label(label: str) -> bool:
1707 return label in _MAIN_PATH_EDGE_LABELS or label in route_verdicts
1709 filtered_edges = [(s, t, lbl) for (s, t, lbl) in edges if _is_main_label(lbl)]
1710 _bfs_visited_order, depth_map = _bfs_order(fsm.initial, filtered_edges)
1711 reachable: set[str] = set(depth_map.keys())
1712 filtered_edges = [
1713 (s, t, lbl) for (s, t, lbl) in filtered_edges if s in reachable and t in reachable
1714 ]
1715 return filtered_edges, reachable
1718def _render_fsm_diagram(
1719 fsm: FSMLoop,
1720 verbose: bool = False,
1721 highlight_state: str | None = None,
1722 highlight_color: str = "32",
1723 edge_label_colors: dict[str, str] | None = None,
1724 badges: dict[str, str] | None = None,
1725 mode: str = "full",
1726 *,
1727 suppress_labels: bool = False,
1728 title_only: bool = False,
1729) -> str:
1730 """Render an adaptive text diagram of the FSM graph.
1732 Detects FSM topology and selects appropriate layout:
1733 - Linear chains: vertical top-to-bottom
1734 - Branching/cyclic: layered Sugiyama-style
1736 Args:
1737 fsm: The FSM loop to render.
1738 verbose: If True, show expanded action content in boxes.
1739 highlight_state: If provided, render this state's box with the highlight color.
1740 highlight_color: ANSI SGR code for the highlighted state (default: green).
1741 edge_label_colors: Optional label→SGR-code mapping for transition labels.
1742 Falls back to hardcoded defaults when None.
1743 badges: Optional glyph-key→string mapping for state type badges.
1744 Falls back to hardcoded defaults when None.
1745 mode: Controls edge scope: "main" (default when filtering active) hides
1746 off-happy-path edges. "full" renders every edge and state. "mini" is
1747 an alias for main-scope; use ``suppress_labels=True, title_only=True``
1748 instead. Callers that need full-detail dumps (e.g. ``ll-loop info``)
1749 keep the default "full".
1750 suppress_labels: If True, edge labels are omitted from all rendered edges.
1751 title_only: If True, state boxes show only the state name (no action body).
1752 """
1753 edges = _collect_edges(fsm)
1754 if mode in ("main", "mini"):
1755 edges, _reachable = _filter_main_path_graph(fsm, edges)
1756 bfs_order_list, bfs_depth = _bfs_order(fsm.initial, edges)
1757 main_path, main_edge_set = _trace_main_path(fsm, edges)
1758 branches, back_edges = _classify_edges(edges, main_edge_set, bfs_order_list)
1760 terminal_states = {name for name, state in fsm.states.items() if state.terminal}
1762 # Collect all states
1763 all_states = list(main_path)
1764 for src, dst, _ in branches:
1765 for s in (src, dst):
1766 if s not in all_states:
1767 all_states.append(s)
1769 # Topology detection
1770 detector = TopologyDetector(edges, main_path, branches, back_edges)
1771 topology = detector.classify()
1773 # Build back-edge set for layout pipeline
1774 back_edge_set: set[tuple[str, str]] = set()
1775 for src, dst, _ in back_edges:
1776 if src != dst:
1777 back_edge_set.add((src, dst))
1779 tw = terminal_width()
1781 if topology == "linear" and len(all_states) <= 1:
1782 # Single state or empty — use simple horizontal
1783 return _render_horizontal_simple(
1784 main_path,
1785 edges,
1786 main_edge_set,
1787 branches,
1788 back_edges,
1789 bfs_order_list,
1790 fsm.initial,
1791 terminal_states,
1792 fsm.states,
1793 verbose,
1794 highlight_state,
1795 highlight_color,
1796 edge_label_colors,
1797 badges,
1798 title_only=title_only or (mode == "mini"),
1799 suppress_labels=suppress_labels or (mode == "mini"),
1800 )
1802 # Compute max node width to determine width constraint
1803 # Quick estimate: widest state name or badge + padding
1804 max_node_w = 30 # reasonable default
1805 for s in all_states:
1806 st = fsm.states.get(s)
1807 badge = _get_state_badge(st, badges)
1808 badge_w = _badge_display_width(badge) if badge else 0
1809 label = s
1810 if s == fsm.initial:
1811 label = "\u2192 " + label
1812 if s in terminal_states:
1813 label = label + " \u25c9"
1814 w = max(_display_width(label), badge_w)
1815 max_node_w = max(max_node_w, w + 4 + 4) # inner + borders + padding
1817 max_width_per_layer = max(1, (tw - 10) // (max_node_w + 4))
1819 # Layer assignment
1820 assigner = LayerAssigner(all_states, edges, back_edge_set, fsm.initial, max_width_per_layer)
1821 layers = assigner.assign()
1823 # Crossing minimization
1824 minimizer = CrossingMinimizer(layers, edges, back_edge_set)
1825 layers = minimizer.minimize()
1827 return _render_layered_diagram(
1828 layers,
1829 edges,
1830 main_edge_set,
1831 branches,
1832 back_edges,
1833 fsm.initial,
1834 terminal_states,
1835 fsm.states,
1836 verbose,
1837 highlight_state,
1838 highlight_color,
1839 edge_label_colors,
1840 badges,
1841 title_only=title_only or (mode == "mini"),
1842 suppress_labels=suppress_labels or (mode == "mini"),
1843 )
1846_PREV_STATE_COLOR = "33" # ANSI orange/yellow border for the just-prior FSM state.
1849def _render_neighborhood_diagram(
1850 fsm: FSMLoop,
1851 active_state: str,
1852 *,
1853 edge_label_colors: dict[str, str] | None = None,
1854 badges: dict[str, str] | None = None,
1855 highlight_color: str = "32",
1856 mode: str = "full",
1857 prev_state: str | None = None,
1858) -> str:
1859 """Render a compact 1-hop neighborhood: predecessors → [active] → successors.
1861 Suitable as a fallback when the full FSM diagram does not fit the viewport.
1862 Bounded: ``max(len(preds), len(succs), 1) * 3`` rows (each state box is 3
1863 lines tall). Returns the empty string when ``active_state`` is not in
1864 ``fsm.states``.
1866 Self-loops are collapsed: a state that only points to itself contributes
1867 neither predecessors nor successors here.
1869 Args:
1870 mode: ``"full"`` (default) includes every edge. ``"main"`` filters edges
1871 through ``_filter_main_path_graph`` so off-happy-path predecessors
1872 (e.g. those connected only via ``on_error``) are hidden. Falls back
1873 to ``"full"`` if the active state would be filtered out.
1874 prev_state: Name of the predecessor the FSM most recently transitioned
1875 from. When that name appears in the rendered pred stack, its box is
1876 drawn with the orange ``_PREV_STATE_COLOR`` border. Silently
1877 skipped if the name is missing from the pred stack.
1878 """
1879 if active_state not in fsm.states:
1880 return ""
1882 edges = _collect_edges(fsm)
1883 if mode == "main":
1884 filtered_edges, reachable = _filter_main_path_graph(fsm, edges)
1885 if active_state in reachable:
1886 edges = filtered_edges
1887 preds = sorted({s for (s, t, _lbl) in edges if t == active_state and s != active_state})
1888 succs = sorted({t for (s, t, _lbl) in edges if s == active_state and t != active_state})
1890 terminal_states = {n for n, st in fsm.states.items() if st.terminal}
1892 def _label(name: str) -> str:
1893 label = name
1894 if name == fsm.initial:
1895 label = "→ " + label
1896 if name in terminal_states:
1897 label = label + " ◉"
1898 return label
1900 pred_labels = [_label(p) for p in preds]
1901 active_label = _label(active_state)
1902 succ_labels = [_label(s) for s in succs]
1904 inner_pred = max((_display_width(lbl) for lbl in pred_labels), default=0)
1905 inner_active = _display_width(active_label)
1906 inner_succ = max((_display_width(lbl) for lbl in succ_labels), default=0)
1908 box_w_pred = inner_pred + 4 if pred_labels else 0
1909 box_w_active = inner_active + 4
1910 box_w_succ = inner_succ + 4 if succ_labels else 0
1912 n_rows = max(len(pred_labels), len(succ_labels), 1)
1913 try:
1914 nd_bg_code: str | None = str(int(highlight_color) + 10)
1915 except (ValueError, TypeError):
1916 nd_bg_code = None
1918 def _make_box(
1919 label: str,
1920 inner_w: int,
1921 highlighted: bool,
1922 *,
1923 border_color: str | None = None,
1924 ) -> list[str]:
1925 top = "┌" + "─" * (inner_w + 2) + "┐"
1926 bot = "└" + "─" * (inner_w + 2) + "┘"
1927 # Pad by display width (not char count) so wide glyphs keep the box square.
1928 padded = label + " " * max(0, inner_w - _display_width(label))
1929 if highlighted:
1930 border_code = f"{highlight_color};{nd_bg_code}" if nd_bg_code else highlight_color
1931 top = colorize(top, border_code)
1932 bot = colorize(bot, border_code)
1933 if nd_bg_code:
1934 mid = (
1935 colorize("│", border_code)
1936 + colorize(" ", nd_bg_code)
1937 + colorize(padded, f"97;{nd_bg_code};1")
1938 + colorize(" ", nd_bg_code)
1939 + colorize("│", border_code)
1940 )
1941 else:
1942 mid = (
1943 colorize("│", border_code)
1944 + " "
1945 + colorize(padded, f"{highlight_color};1")
1946 + " "
1947 + colorize("│", border_code)
1948 )
1949 elif border_color is not None:
1950 top = colorize(top, border_color)
1951 bot = colorize(bot, border_color)
1952 mid = (
1953 colorize("│", border_color)
1954 + " "
1955 + colorize(padded, "1")
1956 + " "
1957 + colorize("│", border_color)
1958 )
1959 else:
1960 mid = "│ " + colorize(padded, "1") + " │"
1961 return [top, mid, bot]
1963 center_idx = (n_rows - 1) // 2
1965 def _build_stack(
1966 labels: list[str],
1967 box_w: int,
1968 *,
1969 color_for: dict[str, str] | None = None,
1970 ) -> list[str]:
1971 rows: list[str] = []
1972 # Align the stack to the arrow row (the active state's slot). Without
1973 # this, a shorter stack always sits at row 0 and the single ``──▶``
1974 # arrow drawn at ``active_line_offset`` points into empty space.
1975 # ``min(center_idx, n_rows - len(labels))`` caps the start so longer
1976 # but still-smaller stacks (e.g. 4 succs vs. 5 preds) don't overflow.
1977 start = max(0, min(center_idx, n_rows - len(labels)))
1978 color_map = color_for or {}
1979 for i in range(n_rows):
1980 j = i - start
1981 if 0 <= j < len(labels):
1982 border = color_map.get(labels[j])
1983 rows.extend(_make_box(labels[j], box_w - 4, False, border_color=border))
1984 else:
1985 rows.extend([" " * box_w] * 3)
1986 return rows
1988 pred_color_for: dict[str, str] = {}
1989 if prev_state is not None and prev_state in preds:
1990 pred_color_for[_label(prev_state)] = _PREV_STATE_COLOR
1992 pred_col = (
1993 _build_stack(pred_labels, box_w_pred, color_for=pred_color_for) if pred_labels else None
1994 )
1995 succ_col = _build_stack(succ_labels, box_w_succ) if succ_labels else None
1996 active_rows: list[str] = []
1997 for i in range(n_rows):
1998 if i == center_idx:
1999 active_rows.extend(_make_box(active_label, inner_active, True))
2000 else:
2001 active_rows.extend([" " * box_w_active] * 3)
2003 arrow = " ──▶ "
2004 arrow_blank = " " * len(arrow)
2005 active_line_offset = center_idx * 3 + 1
2007 total_lines = n_rows * 3
2008 out_lines: list[str] = []
2009 for i in range(total_lines):
2010 parts: list[str] = []
2011 if pred_col is not None:
2012 parts.append(pred_col[i])
2013 parts.append(arrow if i == active_line_offset else arrow_blank)
2014 parts.append(active_rows[i])
2015 if succ_col is not None:
2016 parts.append(arrow if i == active_line_offset else arrow_blank)
2017 parts.append(succ_col[i])
2018 out_lines.append("".join(parts).rstrip())
2020 return "\n".join(out_lines)
2023def _render_horizontal_simple(
2024 main_path: list[str],
2025 edges: list[tuple[str, str, str]],
2026 main_edge_set: set[tuple[str, str]],
2027 branches: list[tuple[str, str, str]],
2028 back_edges: list[tuple[str, str, str]],
2029 bfs_order: list[str],
2030 initial: str,
2031 terminal_states: set[str],
2032 fsm_states: dict[str, StateConfig],
2033 verbose: bool,
2034 highlight_state: str | None,
2035 highlight_color: str,
2036 edge_label_colors: dict[str, str] | None = None,
2037 badges: dict[str, str] | None = None,
2038 title_only: bool = False,
2039 suppress_labels: bool = False,
2040) -> str:
2041 """Simple horizontal rendering for single-state or very simple FSMs.
2043 When ``title_only`` is True, per-state body lines and self-loop labels are suppressed.
2044 When ``suppress_labels`` is True, self-loop markers omit label text.
2045 """
2046 if not main_path:
2047 return ""
2048 all_states = list(main_path)
2049 display_label = _compute_display_labels(all_states, initial, terminal_states)
2051 tw = terminal_width()
2052 num_main = max(1, len(main_path))
2053 if verbose and fsm_states and main_path:
2054 max_box_inner = max(20, min(60, (tw - 4) // num_main - 6))
2055 else:
2056 max_box_inner = max(20, min(40, (tw - 4) // num_main - 6))
2058 box_inner, box_width, box_height, box_badge = _compute_box_sizes(
2059 all_states,
2060 display_label,
2061 fsm_states,
2062 verbose,
2063 max_box_inner,
2064 badges,
2065 title_only=title_only,
2066 )
2068 main_height = max((box_height[s] for s in main_path), default=3)
2069 total_width = tw
2071 # Column positions
2072 col_start: dict[str, int] = {}
2073 col_center: dict[str, int] = {}
2074 x = 2
2075 for i, sname in enumerate(main_path):
2076 col_start[sname] = x
2077 col_center[sname] = x + box_width[sname] // 2
2078 x += box_width[sname]
2079 if i < len(main_path) - 1:
2080 x += 4
2082 rows: list[list[str]] = [[" "] * total_width for _ in range(main_height)]
2084 for sname in main_path:
2085 is_highlighted = highlight_state is not None and sname == highlight_state
2086 _draw_box(
2087 rows,
2088 0,
2089 col_start[sname],
2090 box_width[sname],
2091 main_height,
2092 box_inner[sname],
2093 is_highlighted,
2094 highlight_color,
2095 badge=box_badge[sname],
2096 )
2098 # Self-loops
2099 self_loops_list = [(s, d, lbl) for s, d, lbl in back_edges if s == d]
2100 lines = ["".join(row).rstrip() for row in rows]
2101 if self_loops_list:
2102 self_labels: dict[str, list[str]] = {}
2103 for src, _, label in self_loops_list:
2104 self_labels.setdefault(src, []).append(label)
2105 for sname, labels in self_labels.items():
2106 marker = "\u21ba" if suppress_labels else "\u21ba " + ", ".join(labels)
2107 self_row = [" "] * total_width
2108 cx = col_center.get(sname, 0)
2109 pos = max(0, cx - len(marker) // 2)
2110 for j, ch in enumerate(marker):
2111 if pos + j < total_width:
2112 self_row[pos + j] = ch
2113 lines.append("".join(self_row).rstrip())
2115 diagram_indent = max(0, (tw - (x + 4)) // 2)
2116 if diagram_indent > 0:
2117 lines = [" " * diagram_indent + ln if ln.strip() else ln for ln in lines]
2119 return _colorize_diagram_labels("\n".join(lines), edge_label_colors)