This document describes the implementation plan for adding group boxes to RetroFlow. Group boxes allow users to visually cluster related nodes together within a labeled container, making diagrams easier to read and understand.
A good example of an isolated group box:
GROUP TITLE
┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐
┆ ┆░
┆ ┌───────────┐ ┆░
┆ │ NODE A │░ ┆░
┆ └───────────┘░ ┆░
┆ ░░░░░░░░░░░░ ┆░
┆ │ ┆░
┆ ▼ ┆░
┆ ┌───────────┐ ┆░
┆ │ NODE B │░ ┆░
┆ └───────────┘░ ┆░
┆ ░░░░░░░░░░░░ ┆░
└┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘░
░░░░░░░░░░░░░░░░░░░░░
Groups are defined at the top of input text, before edge definitions:
[GROUP TITLE: node1 node2 node3]
[ANOTHER GROUP: nodeA nodeB]
node1 -> node2
node2 -> node3
nodeA -> nodeB
node3 -> nodeA
- Text before the colon is the group title (centered above the group box)
- Text after the colon is a space-separated list of node names
- Multi-word node names are supported (matched against nodes found in edges)
| Requirement | Description |
|---|---|
| Border style | Dashed lines using ┄ or ╌ characters (horizontal) and ┆ or ╎ (vertical) |
| Shadows | Group boxes have shadows on right and bottom edges (same as text boxes) |
| Title | Centered text positioned on the top edge of the group box |
| Padding | Internal padding between group border and contained nodes |
| Node styling | Nodes inside groups retain their normal styling (solid border, shadow) |
See the grouped_boxes.png image for an example of a clean layout with group boxes.
- Group hierarchy takes priority: Nodes within a group are positioned together, even if the global graph structure would place them in different layers
- Single membership: A node can belong to at most one group (enforced during parsing)
- Ungrouped node margins: When groups exist, ungrouped nodes receive extra margin to prevent visual crowding
- Both directions: Works in TB (top-to-bottom) and LR (left-to-right) modes
- No overlapping: VERY IMPORTANT: Under no circumstances should text boxes overlap or sit too closely with each other, no group boxes should overlap or sit too closely with each other, and no group box boundaries should overlap or sit too closely with text boxes.
| File | Purpose |
|---|---|
src/retroflow/groups.py |
Group data models and group-aware layout utilities |
| File | Changes |
|---|---|
parser.py |
Parse group definitions from input text |
layout.py |
Group-aware layer assignment |
positioning.py |
Calculate group bounding boxes and add group padding |
renderer.py |
Add GroupBoxRenderer class for dashed boxes |
generator.py |
Orchestrate group parsing, layout, and rendering |
models.py |
Add GroupDefinition and GroupBoundary models |
@dataclass
class GroupDefinition:
"""Definition of a node group from parsed input."""
name: str # Group title/label
members: List[str] # List of node names in this group
order: int # Order in which group was defined (for z-ordering)@dataclass
class GroupBoundary:
"""Calculated boundaries for a rendered group box."""
name: str # Group title
members: List[str] # Node names in this group
# Canvas coordinates (calculated during positioning)
x: int # Left edge (content area, not including title)
y: int # Top edge (below title)
width: int # Width of group box
height: int # Height of group box (not including title)
# Title positioning
title_x: int # X position for centered title
title_y: int # Y position for title (above box)
title_width: int # Width of title text@dataclass
class ParseResult:
"""Result of parsing input text."""
connections: List[Tuple[str, str]] # Edge connections
groups: List[GroupDefinition] # Group definitionsInput format:
[GROUP NAME: node1 node2 node3]
Parsing logic:
- Scan lines at the beginning of input for group definitions
- Group definition regex:
^\s*\[([^:]+):\s*(.+)\]\s*$ - Extract group name (before colon) and member list (after colon)
- Member list is space-separated, but must handle multi-word node names
Challenge: Multi-word node names
The syntax [MY GROUP: Node One Node Two Node Three] is ambiguous. We need a strategy:
Strategy A (Recommended): Two-pass parsing
- First, parse all edges to discover all valid node names
- Then, parse group member lists by matching against known node names
- Use greedy matching (longest match first) for multi-word names
Strategy B: Require quoted node names in groups
[MY GROUP: "Node One" "Node Two"]
Recommendation: Use Strategy A to maintain syntax simplicity.
- Error if a node appears in multiple groups
- Warning if a group references a node that doesn't exist in any edge
- Error if group definition appears after edge definitions (enforce order)
class Parser:
def parse(self, input_text: str) -> ParseResult:
"""Parse input text into connections and groups."""
...
def _parse_groups(self, lines: List[str], all_nodes: Set[str]) -> List[GroupDefinition]:
"""Parse group definitions, matching against known nodes."""
...
def _match_node_names(self, member_text: str, known_nodes: Set[str]) -> List[str]:
"""Match member text against known node names (greedy longest-first)."""
...The current layout uses topological sorting to assign layers. With groups, we need to modify this:
Current behavior:
- Each node gets assigned to a layer based on its predecessors
- Layer = max(predecessor layers) + 1
New behavior with groups:
- Calculate "natural" layers for all nodes (as currently done)
- For each group, determine the group's layer span:
group_min_layer = min(node.layer for node in group.members)group_max_layer = max(node.layer for node in group.members)
- Compress group members to top of span: Move all group members to start at
group_min_layer - Re-calculate internal ordering within the group
- Adjust downstream nodes if needed
Example:
Before group adjustment:
Layer 0: [A] (A is in GROUP1)
Layer 1: [B, X] (B is in GROUP1, X is ungrouped)
Layer 2: [C] (C is in GROUP1)
After group adjustment (GROUP1 members compressed):
Layer 0: [A, B, C] (all GROUP1 members)
Layer 1: [X] (ungrouped, shifted down if needed)
Within a group, nodes should be ordered to minimize edge crossings (using existing barycenter heuristic), but constrained to stay within the group's layer span.
@dataclass
class LayoutResult:
nodes: Dict[str, NodeLayout]
layers: List[List[str]]
edges: List[Tuple[str, str]]
back_edges: Set[Tuple[str, str]]
has_cycles: bool
groups: List[GroupDefinition] # NEW: pass through for positioning
node_to_group: Dict[str, str] # NEW: node_name -> group_name mappingAfter calculating individual node positions, compute group boundaries:
def calculate_group_boundaries(
self,
groups: List[GroupDefinition],
box_positions: Dict[str, Tuple[int, int]],
box_dimensions: Dict[str, BoxDimensions],
padding: int = 2,
title_height: int = 1,
) -> List[GroupBoundary]:
"""
Calculate bounding boxes for each group.
For each group:
1. Find min/max x and y of all member nodes (including their shadows)
2. Add internal padding
3. Calculate title position (centered above)
"""When groups exist, modify spacing calculations:
# Constants for group spacing
GROUP_INTERNAL_PADDING = 2 # Space between group border and nodes
GROUP_EXTERNAL_MARGIN = 3 # Space between group box and adjacent elements
GROUP_TITLE_HEIGHT = 1 # Height reserved for group titleCanvas size calculation must account for:
- Group box borders and shadows
- Group titles above boxes
- Extra margins around groups
Add new character sets for dashed lines:
# Dashed box-drawing characters
DASHED_BOX_CHARS = {
"horizontal": "┄", # or "╌" for lighter dashes
"vertical": "┆", # or "╎" for lighter dashes
"top_left": "┌", # Corners remain solid for clarity
"top_right": "┐",
"bottom_left": "└",
"bottom_right": "┘",
"shadow": "░",
}class GroupBoxRenderer:
"""Renders group boxes with dashed borders and shadows."""
def __init__(self, shadow: bool = True):
self.shadow = shadow
self.chars = DASHED_BOX_CHARS
def draw_group_box(
self,
canvas: Canvas,
boundary: GroupBoundary,
) -> None:
"""
Draw a group box with:
- Dashed border
- Shadow on right and bottom
- Centered title on top edge
"""
...
def _draw_title_on_border(
self,
canvas: Canvas,
x: int,
y: int,
title: str,
box_width: int,
) -> None:
"""Draw title text centered on the top border line."""
# Title sits on the top border, breaking the dashed line
# Example: ┌─ MY GROUP ─┐
...Groups must be drawn before node boxes so nodes appear "on top":
1. Draw group boxes (dashed borders, shadows, titles)
2. Draw node boxes (solid borders, shadows, text)
3. Draw edges (lines, arrows)
def generate(self, input_text: str, title: Optional[str] = None, debug: bool = False) -> str:
# 1. Parse input (now returns ParseResult with groups)
parse_result = self.parser.parse(input_text)
connections = parse_result.connections
groups = parse_result.groups
# 2. Run layout (now group-aware)
layout_result = self.layout_engine.layout(connections, groups)
# 3. Calculate box dimensions
box_dimensions = self.position_calculator.calculate_all_box_dimensions(layout_result)
# 4. Calculate positions (with group padding)
box_positions = self.position_calculator.calculate_positions(
layout_result, box_dimensions, groups=groups
)
# 5. Calculate group boundaries
group_boundaries = self.position_calculator.calculate_group_boundaries(
groups, box_positions, box_dimensions
)
# 6. Create canvas
canvas = Canvas(width, height)
# 7. Draw groups FIRST (so nodes draw on top)
self._draw_groups(canvas, group_boundaries)
# 8. Draw node boxes
self._draw_boxes(canvas, box_dimensions, box_positions, layout_result)
# 9. Draw edges
self._draw_edges(canvas, ...)
return canvas.render()Edges that connect nodes in different groups (or grouped to ungrouped) must cross group boundaries. The current edge routing should handle this naturally since it routes around boxes, not groups.
No special handling needed - edges will route through the group's internal padding space.
Edges between nodes within the same group should route normally. The group box is drawn first, so edge lines will appear on top of the dashed border (which is fine visually).
Back edges (cycle edges) currently route along the left margin (TB) or top margin (LR). This should continue to work with groups, but may need adjustment if a back edge's source/target is inside a group.
Potential issue: Back edge margin calculation will absolutely need to account for group box width.
| Test Area | Test Cases |
|---|---|
| Parser | Group syntax parsing, multi-word node matching, validation errors |
| Layout | Group compression, intra-group ordering, ungrouped node handling |
| Positioning | Group boundary calculation, padding, canvas sizing |
| Rendering | Dashed box drawing, title centering, shadow rendering |
- Basic group - Single group with 2-3 nodes
- Multiple groups - Two or more non-overlapping groups
- Mixed - Some nodes grouped, some ungrouped
- Groups spanning layers - Group members at different natural layers
- Edges crossing groups - Connections between groups
- Large groups - Groups with 5+ nodes
- LR mode - All above tests in horizontal orientation
- With cycles - Groups containing nodes involved in back edges
- Long titles - Group titles that need wrapping or truncation
Use the existing extensive_retroflow_testing.py tests 41-81 as the baseline. These should produce valid output once the feature is implemented. Ensure that the output files of these tests don't contain broken arrow lines (AKA edges), that there aren't free floating arrows or missing arrows, and that no boxes (whether group or text boxes) overlap.
Issue: [GROUP: Node One Node Two] - is this two nodes "Node One" and "Node Two", or three nodes "Node", "One Node", "Two"?
Mitigation: Two-pass parsing with greedy longest-match against known node names from edges.
Issue: User defines [GROUP: A B C] but node C never appears in any edge.
Mitigation:
- Warn but don't error (node might be intentionally orphaned)
- Still include the node in the group's bounding box
- Node won't be drawn (no box) but title still shows
Alternative: Error and require all group members to exist in edges.
Issue: Compressing group members to the same layer region may increase edge crossings.
Mitigation: Accept this trade-off. Group visual clustering takes priority over global edge crossing minimization. Document this behavior.
Issue: Title like "FASTAPI ML MICROSERVICE WITH AUTHENTICATION" may be wider than the group contents.
Mitigation:
- Expand group box width to fit title
- Or truncate/wrap title (with "..." if truncated)
Recommendation: Expand box to fit title (more readable).
Issue: Group defined but all members removed from edges.
Mitigation: Skip rendering empty groups. Warn during parsing.
Issue: Two groups whose members are interspersed in the layout may produce overlapping boxes.
Mitigation: The group-aware layout (Phase 2) should prevent this by clustering group members. If it still happens, this is an edge case we accept for v1.
Issue: Many groups with many members could slow down positioning.
Mitigation: The algorithm is O(n) per group for boundary calculation, which should be fine for reasonable diagram sizes. Not a concern for v1.
Recommended order for implementing phases:
- Phase 1 (Parser) - Can be tested independently
- Phase 4 (Renderer) - Implement
GroupBoxRenderer, test with hardcoded boundaries - Phase 3 (Positioning) - Calculate boundaries from real positions
- Phase 5 (Generator) - Wire everything together (basic version)
- Phase 2 (Layout) - Group-aware layer assignment (most complex)
- Phase 6 (Edges) - Verify/fix edge routing with groups
- Polish - Debug tracing, error messages, documentation
The feature is complete when:
- All 40 grouped tests (41-81) in
extensive_retroflow_testing.pyproduce valid, readable output - Grouped diagrams render correctly in both TB and LR modes
- Edges route cleanly across group boundaries
- Test coverage remains above 90%
- No regressions in non-grouped diagrams (tests 0-40)
grouped_boxes.pngcan be reproduced from equivalent input text
GROUP TITLE
┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┐
┆ ┆░
┆ ┌───────────┐ ┆░
┆ │ NODE A │░ ┆░
┆ └───────────┘░ ┆░
┆ ░░░░░░░░░░░░ ┆░
┆ │ ┆░
┆ ▼ ┆░
┆ ┌───────────┐ ┆░
┆ │ NODE B │░ ┆░
┆ └───────────┘░ ┆░
┆ ░░░░░░░░░░░░ ┆░
└┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘░
░░░░░░░░░░░░░░░░░░░░░
| Character | Unicode | Name |
|---|---|---|
| ┄ | U+2504 | Box Drawings Light Triple Dash Horizontal |
| ┆ | U+2506 | Box Drawings Light Triple Dash Vertical |
| ╌ | U+254C | Box Drawings Light Double Dash Horizontal |
| ╎ | U+254E | Box Drawings Light Double Dash Vertical |
| Character | Unicode | Name |
|---|---|---|
| ┌ | U+250C | Box Drawings Light Down and Right |
| ┐ | U+2510 | Box Drawings Light Down and Left |
| └ | U+2514 | Box Drawings Light Up and Right |
| ┘ | U+2518 | Box Drawings Light Up and Left |
This section captures key insights learned during the actual implementation and debugging of the group box feature.
The most important layout rule for groups:
| Flow Direction | Node Arrangement WITHIN Groups |
|---|---|
| TB (Top-to-Bottom) | Nodes arranged HORIZONTALLY (side-by-side) |
| LR (Left-to-Right) | Nodes STACKED VERTICALLY |
Why? This makes groups visually compact and easier to read. Nodes are arranged perpendicular to the flow direction.
Visual Reference (see grouped_boxes.png):
- In an LR diagram, the "FASTAPI ML MICROSERVICE" group has all its nodes (Search, Query, SQLite, Wiki, ML, Ranker) stacked vertically within a tall, narrow group box.
Implementation: All nodes in a group must share the same primary axis position:
- LR mode: All group members share the same X position (column), stacked at different Y positions
- TB mode: All group members share the same Y position (row), placed at different X positions
The following constants are essential for preventing visual issues:
GROUP_INTERNAL_PADDING = 3 # Space between group border and contained nodes
GROUP_EXTERNAL_MARGIN = 4 # Space between group boxes (prevents overlap)
GROUP_TITLE_HEIGHT = 1 # Height reserved for group title
GROUP_EDGE_MARGIN = 3 # Minimum space between edges and group bordersWhy these values?
GROUP_INTERNAL_PADDING = 3(not 2): Provides enough breathing room inside the groupGROUP_EXTERNAL_MARGIN = 4: Prevents groups from touching/overlapping when in adjacent layersGROUP_EDGE_MARGIN = 3: Ensures back-edges don't run too close to group box borders
Groups can overlap when nodes from different groups are in adjacent layers. The fix requires:
-
Extra inter-layer spacing: When different groups have nodes in adjacent layers, add extra vertical (TB) or horizontal (LR) spacing between those layers.
-
Overlap detection and resolution: After calculating group boundaries, check for overlaps and shift positions as needed.
-
The formula for detecting vertical overlap (TB mode):
If groups overlap horizontally AND group1_bottom + margin > group2_top: Shift group2's members down by: group1_bottom + margin - group2_top + 1
When group members span multiple layers (which is common due to edges between them):
- Determine the group's "anchor" layer: The minimum layer index of any member
- Position all group members at the anchor layer's x-position (LR) or y-position (TB)
- Stack members perpendicular to flow within that anchor position
This ensures the group box is compact rather than stretched across the entire diagram.
When groups are present, back-edge margins need adjustment:
if groups:
back_edge_margin = max(back_edge_margin, GROUP_EDGE_MARGIN + 2)This ensures edges routing around the diagram don't run too close to group box borders.
| Pitfall | Solution |
|---|---|
| Groups overlapping at layer boundaries | Add GROUP_EXTERNAL_MARGIN spacing between layers with different groups |
| Nodes spread in flow direction within group | Anchor all group members to leftmost/topmost layer and stack perpendicular |
| Edges too close to group borders | Add GROUP_EDGE_MARGIN to back-edge margin calculations |
| Group title doesn't fit | Expand group width to accommodate title (already in plan) |
The key positioning logic is in positioning.py:
calculate_group_aware_positions()- Main entry point for group-aware positioning_calculate_positions_tb_grouped()- TB mode: arranges group members horizontally_calculate_positions_lr_grouped()- LR mode: stacks group members verticallyresolve_group_overlaps()- Detects and fixes overlapping group boundariescalculate_group_edge_margin()- Calculates extra margin for edges
Plan version: 1.1 Created: January 2026 Updated: January 2026 (added implementation insights) Author: Claude Code + Human collaboration