This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch feature/CAMEL-23672-tui-diagram
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 7ea552b2812da885618c67192e419e0d236bab72
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Jun 3 21:27:47 2026 +0200

    CAMEL-23672: camel-tui - Native route EIP diagram widget with node 
navigation
    
    Renders route EIP diagrams directly to TamboUI Buffer, bypassing the ASCII
    renderer. EIP nodes are drawn with type-specific colors, scope boxes use
    dashed borders, and edges show metrics counters. Arrow keys navigate between
    EIP nodes with an info panel showing processor-level metrics (total, failed,
    inflight, processing times). Drill-down and escape use cached layout data
    so no IPC is needed when switching between topology and route views.
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../camel/diagram/RouteDiagramLayoutEngine.java    |  10 +-
 .../jbang/core/commands/tui/DiagramSupport.java    | 183 ++++++++++
 .../dsl/jbang/core/commands/tui/DiagramTab.java    | 120 +++++-
 .../core/commands/tui/diagram/DiagramColors.java   |   4 +-
 .../commands/tui/diagram/RouteDiagramWidget.java   | 404 +++++++++++++++++++++
 5 files changed, 713 insertions(+), 8 deletions(-)

diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
index 7681aba8e338..851a6ca65d56 100644
--- 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
@@ -57,23 +57,23 @@ public class RouteDiagramLayoutEngine {
     static final Set<String> BRANCHING_EIPS = Set.of(
             "choice", "multicast", "doTry", "loadBalance", "recipientList", 
"circuitBreaker");
 
-    static final Set<String> BRANCH_CHILD_TYPES = Set.of(
+    public static final Set<String> BRANCH_CHILD_TYPES = Set.of(
             "when", "otherwise", "doCatch", "doFinally", "onFallback");
 
     static final Set<String> STRUCTURAL_TYPES = Set.of(
             "route", "from");
 
-    static class Bounds {
-        int minX, minY, maxX, maxY;
+    public static class Bounds {
+        public int minX, minY, maxX, maxY;
 
-        Bounds(int minX, int minY, int maxX, int maxY) {
+        public Bounds(int minX, int minY, int maxX, int maxY) {
             this.minX = minX;
             this.minY = minY;
             this.maxX = maxX;
             this.maxY = maxY;
         }
 
-        void expand(Bounds other) {
+        public void expand(Bounds other) {
             minX = Math.min(minX, other.minX);
             minY = Math.min(minY, other.minY);
             maxX = Math.max(maxX, other.maxX);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
index 340dcc7a2ef1..4b6174adea8e 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
@@ -309,6 +309,8 @@ class DiagramSupport {
         topologyNodeWidth = 0;
         routeLayouts = Collections.emptyMap();
         selectedNodeIndex = -1;
+        eipNodeBoxes = Collections.emptyList();
+        selectedEipNodeIndex = -1;
         scrollY = 0;
         scrollX = 0;
     }
@@ -589,6 +591,187 @@ class DiagramSupport {
         }
     }
 
+    // ---- Route EIP node selection ----
+
+    private 
List<org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget.EipNodeBox>
 eipNodeBoxes
+            = Collections.emptyList();
+    private int selectedEipNodeIndex = -1;
+
+    
List<org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget.EipNodeBox>
 getEipNodeBoxes() {
+        return eipNodeBoxes;
+    }
+
+    int getSelectedEipNodeIndex() {
+        return selectedEipNodeIndex;
+    }
+
+    void setSelectedEipNodeIndex(int idx) {
+        this.selectedEipNodeIndex = idx;
+    }
+
+    
org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget.EipNodeBox
 getSelectedEipNodeBox() {
+        if (selectedEipNodeIndex >= 0 && selectedEipNodeIndex < 
eipNodeBoxes.size()) {
+            return eipNodeBoxes.get(selectedEipNodeIndex);
+        }
+        return null;
+    }
+
+    void selectEipNodeUp() {
+        if (eipNodeBoxes.isEmpty()) {
+            return;
+        }
+        if (selectedEipNodeIndex < 0) {
+            selectedEipNodeIndex = 0;
+            return;
+        }
+        // Move to previous node in list (follow flow upward)
+        if (selectedEipNodeIndex > 0) {
+            selectedEipNodeIndex--;
+        }
+    }
+
+    void selectEipNodeDown() {
+        if (eipNodeBoxes.isEmpty()) {
+            return;
+        }
+        if (selectedEipNodeIndex < 0) {
+            selectedEipNodeIndex = 0;
+            return;
+        }
+        if (selectedEipNodeIndex < eipNodeBoxes.size() - 1) {
+            selectedEipNodeIndex++;
+        }
+    }
+
+    void selectEipNodeLeft() {
+        if (eipNodeBoxes.isEmpty() || selectedEipNodeIndex < 0) {
+            return;
+        }
+        var current = eipNodeBoxes.get(selectedEipNodeIndex);
+        int bestIdx = -1;
+        int bestCol = -1;
+        for (int i = 0; i < eipNodeBoxes.size(); i++) {
+            var nb = eipNodeBoxes.get(i);
+            if (nb.startCol() < current.startCol()
+                    && Math.abs(nb.startRow() - current.startRow()) <= 2) {
+                if (nb.startCol() > bestCol) {
+                    bestIdx = i;
+                    bestCol = nb.startCol();
+                }
+            }
+        }
+        if (bestIdx >= 0) {
+            selectedEipNodeIndex = bestIdx;
+        }
+    }
+
+    void selectEipNodeRight() {
+        if (eipNodeBoxes.isEmpty() || selectedEipNodeIndex < 0) {
+            return;
+        }
+        var current = eipNodeBoxes.get(selectedEipNodeIndex);
+        int bestIdx = -1;
+        int bestCol = Integer.MAX_VALUE;
+        for (int i = 0; i < eipNodeBoxes.size(); i++) {
+            var nb = eipNodeBoxes.get(i);
+            if (nb.startCol() > current.startCol()
+                    && Math.abs(nb.startRow() - current.startRow()) <= 2) {
+                if (nb.startCol() < bestCol) {
+                    bestIdx = i;
+                    bestCol = nb.startCol();
+                }
+            }
+        }
+        if (bestIdx >= 0) {
+            selectedEipNodeIndex = bestIdx;
+        }
+    }
+
+    void scrollToSelectedEipNode() {
+        if (selectedEipNodeIndex < 0 || selectedEipNodeIndex >= 
eipNodeBoxes.size()) {
+            return;
+        }
+        var box = eipNodeBoxes.get(selectedEipNodeIndex);
+        if (lastVisibleHeight > 0) {
+            if (box.startRow() < scrollY + 1) {
+                scrollY = Math.max(0, box.startRow() - 1);
+            }
+            if (box.endRow() >= scrollY + lastVisibleHeight - 1) {
+                scrollY = box.endRow() - lastVisibleHeight + 2;
+            }
+        }
+        if (lastVisibleWidth > 0) {
+            if (box.startCol() < scrollX + 1) {
+                scrollX = Math.max(0, box.startCol() - 1);
+            }
+            if (box.endCol() >= scrollX + lastVisibleWidth - 1) {
+                scrollX = box.endCol() - lastVisibleWidth + 2;
+            }
+        }
+    }
+
+    void renderNativeRouteDiagram(
+            Frame frame, Rect area, String title, boolean metrics,
+            RouteDiagramLayoutEngine.LayoutRoute routeLayout) {
+        Block block = Block.builder()
+                .borderType(BorderType.ROUNDED)
+                .title(title)
+                .build();
+        frame.renderWidget(block, area);
+
+        Rect inner = block.inner(area);
+        int nw = RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH * 
RouteDiagramLayoutEngine.SCALE;
+
+        var widget = new 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget(
+                routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, 
metrics);
+
+        int totalRows = widget.getTotalRows();
+        int totalCols = widget.getTotalCols();
+        int visibleLines = Math.max(1, inner.height() - 1);
+        int visibleCols = Math.max(1, inner.width() - 1);
+        lastVisibleHeight = visibleLines;
+        lastVisibleWidth = visibleCols;
+
+        int maxVScroll = Math.max(0, totalRows - visibleLines);
+        int maxHScroll = Math.max(0, totalCols - visibleCols);
+        scrollY = Math.min(scrollY, maxVScroll);
+        scrollX = Math.min(scrollX, maxHScroll);
+
+        var finalWidget = new 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget(
+                routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, 
metrics);
+
+        List<Rect> vChunks = Layout.vertical()
+                .constraints(Constraint.fill(), Constraint.length(1))
+                .split(inner);
+
+        List<Rect> hChunks = Layout.horizontal()
+                .constraints(Constraint.fill(), Constraint.length(1))
+                .split(vChunks.get(0));
+
+        frame.renderWidget(finalWidget, hChunks.get(0));
+
+        eipNodeBoxes = new ArrayList<>(finalWidget.getNodeBoxes());
+        if (selectedEipNodeIndex < 0 && !eipNodeBoxes.isEmpty()) {
+            selectedEipNodeIndex = 0;
+        }
+
+        vScrollState.contentLength(totalRows);
+        vScrollState.viewportContentLength(visibleLines);
+        vScrollState.position(scrollY);
+        frame.renderStatefulWidget(
+                Scrollbar.builder().build(),
+                hChunks.get(1), vScrollState);
+
+        if (totalCols > visibleCols) {
+            hScrollState.contentLength(totalCols);
+            hScrollState.viewportContentLength(visibleCols);
+            hScrollState.position(scrollX);
+            frame.renderStatefulWidget(
+                    Scrollbar.horizontal(),
+                    vChunks.get(1), hScrollState);
+        }
+    }
+
     // ---- Rendering (legacy text/image) ----
 
     void renderDiagram(Frame frame, Rect area, String title) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
index 80128534e8c3..af4d2e6db5e8 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
@@ -80,6 +80,30 @@ class DiagramTab implements MonitorTab {
             }
         }
 
+        // EIP node navigation in route drill-down mode
+        if (!topologyMode && diagram.isShowDiagram() && 
!diagram.getEipNodeBoxes().isEmpty()) {
+            if (ke.isUp()) {
+                diagram.selectEipNodeUp();
+                diagram.scrollToSelectedEipNode();
+                return true;
+            }
+            if (ke.isDown()) {
+                diagram.selectEipNodeDown();
+                diagram.scrollToSelectedEipNode();
+                return true;
+            }
+            if (ke.isLeft()) {
+                diagram.selectEipNodeLeft();
+                diagram.scrollToSelectedEipNode();
+                return true;
+            }
+            if (ke.isRight()) {
+                diagram.selectEipNodeRight();
+                diagram.scrollToSelectedEipNode();
+                return true;
+            }
+        }
+
         if (diagram.handleScrollKeys(ke)) {
             return true;
         }
@@ -117,7 +141,12 @@ class DiagramTab implements MonitorTab {
                     drillDownRouteId = selectedRouteId;
                     topologyMode = false;
                     diagram.setTopologyMode(false);
+                    diagram.setSelectedEipNodeIndex(-1);
                     diagram.endLoad();
+                    // Use cached route layout if available (no IPC needed)
+                    if (diagram.getRouteLayout(selectedRouteId) != null) {
+                        return true;
+                    }
                     reloadDiagram();
                 }
             }
@@ -133,6 +162,11 @@ class DiagramTab implements MonitorTab {
             diagram.setPendingSelectionRouteId(drillDownRouteId);
             topologyMode = true;
             diagram.setTopologyMode(true);
+            diagram.setSelectedEipNodeIndex(-1);
+            // If topology layout is cached, just switch view without IPC
+            if (diagram.hasNativeLayout()) {
+                return true;
+            }
             diagram.endLoad();
             reloadDiagram();
             return true;
@@ -194,6 +228,19 @@ class DiagramTab implements MonitorTab {
                 } else {
                     diagram.renderNativeDiagram(frame, area, title, 
diagramMetrics);
                 }
+            } else if (!topologyMode && drillDownRouteId != null
+                    && diagram.getRouteLayout(drillDownRouteId) != null) {
+                var routeLayout = diagram.getRouteLayout(drillDownRouteId);
+                if (area.width() > 60) {
+                    int panelWidth = 30;
+                    List<Rect> hChunks = Layout.horizontal()
+                            .constraints(Constraint.length(panelWidth), 
Constraint.fill())
+                            .split(area);
+                    renderEipInfoPanel(frame, hChunks.get(0));
+                    diagram.renderNativeRouteDiagram(frame, hChunks.get(1), 
title, diagramMetrics, routeLayout);
+                } else {
+                    diagram.renderNativeRouteDiagram(frame, area, title, 
diagramMetrics, routeLayout);
+                }
             } else {
                 if (selectedRouteId != null && area.width() > 60) {
                     int panelWidth = 30;
@@ -335,10 +382,81 @@ class DiagramTab implements MonitorTab {
         frame.renderWidget(paragraph, area);
     }
 
+    private void renderEipInfoPanel(Frame frame, Rect area) {
+        List<Line> lines = new ArrayList<>();
+        var selected = diagram.getSelectedEipNodeBox();
+        if (selected != null && selected.layoutNode() != null) {
+            var ln = selected.layoutNode();
+
+            String typeLabel = ln.type != null ? ln.type : "unknown";
+            Color eipColor = 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.DiagramColors.getEipColor(typeLabel);
+            lines.add(Line.from(
+                    Span.styled(" [" + typeLabel + "]", 
Style.EMPTY.fg(eipColor).bold())));
+
+            String label = String.join("", ln.wrappedLines);
+            if (!label.isBlank()) {
+                lines.add(Line.from(
+                        Span.styled(" ", Style.EMPTY.dim()),
+                        Span.raw(label)));
+            }
+
+            if (ln.id != null) {
+                lines.add(Line.from(
+                        Span.styled(" ID: ", Style.EMPTY.dim()),
+                        Span.raw(ln.id)));
+            }
+
+            if (ln.treeNode != null && ln.treeNode.info.stat != null) {
+                var stat = ln.treeNode.info.stat;
+                lines.add(Line.from(Span.raw("")));
+                lines.add(Line.from(
+                        Span.styled(" Total:    ", Style.EMPTY.dim()),
+                        Span.raw(String.valueOf(stat.exchangesTotal))));
+                Style failStyle = stat.exchangesFailed > 0
+                        ? Style.EMPTY.fg(Color.LIGHT_RED).bold() : Style.EMPTY;
+                lines.add(Line.from(
+                        Span.styled(" Failed:   ", Style.EMPTY.dim()),
+                        Span.styled(String.valueOf(stat.exchangesFailed), 
failStyle)));
+                lines.add(Line.from(
+                        Span.styled(" Inflight: ", Style.EMPTY.dim()),
+                        Span.raw(String.valueOf(stat.exchangesInflight))));
+
+                if (stat.exchangesTotal > 0) {
+                    lines.add(Line.from(Span.raw("")));
+                    lines.add(Line.from(
+                            Span.styled(" Mean: ", Style.EMPTY.dim()),
+                            Span.raw(stat.meanProcessingTime + " ms")));
+                    lines.add(Line.from(
+                            Span.styled(" Max:  ", Style.EMPTY.dim()),
+                            Span.raw(stat.maxProcessingTime + " ms")));
+                    lines.add(Line.from(
+                            Span.styled(" Min:  ", Style.EMPTY.dim()),
+                            Span.raw(stat.minProcessingTime + " ms")));
+                    lines.add(Line.from(
+                            Span.styled(" Last: ", Style.EMPTY.dim()),
+                            Span.raw(stat.lastProcessingTime + " ms")));
+                }
+            }
+        } else {
+            lines.add(Line.from(Span.styled(" (no node selected)", 
Style.EMPTY.dim())));
+        }
+
+        Paragraph paragraph = Paragraph.builder()
+                .text(Text.from(lines))
+                .block(Block.builder().borderType(BorderType.ROUNDED)
+                        .title(" EIP Info ").build())
+                .build();
+        frame.renderWidget(paragraph, area);
+    }
+
     @Override
     public void renderFooter(List<Span> spans) {
         if (diagram.isShowDiagram()) {
-            if (!topologyMode) {
+            if (!topologyMode && !diagram.getEipNodeBoxes().isEmpty()) {
+                hint(spans, "Esc", "back");
+                hint(spans, "↑↓←→", "navigate");
+                hint(spans, "PgUp/PgDn", "page");
+            } else if (!topologyMode) {
                 hint(spans, "Esc", "back");
                 hint(spans, "↑↓←→", "scroll");
                 hint(spans, "PgUp/PgDn", "page");
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java
index a13f5579b5c9..2c24681749f0 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java
@@ -19,7 +19,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui.diagram;
 import dev.tamboui.style.Color;
 import dev.tamboui.style.Style;
 
-final class DiagramColors {
+public final class DiagramColors {
 
     static final Color OK_COLOR = Color.GREEN;
     static final Color FAIL_COLOR = Color.LIGHT_RED;
@@ -50,7 +50,7 @@ final class DiagramColors {
     private DiagramColors() {
     }
 
-    static Color getEipColor(String type) {
+    public static Color getEipColor(String type) {
         if (type == null) {
             return Color.GRAY;
         }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
new file mode 100644
index 000000000000..d3fbccac2301
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
@@ -0,0 +1,404 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui.diagram;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.widget.Widget;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.Bounds;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutNode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.StatInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.TreeNode;
+
+import static 
org.apache.camel.diagram.RouteDiagramLayoutEngine.BRANCH_CHILD_TYPES;
+import static org.apache.camel.diagram.RouteDiagramLayoutEngine.PADDING;
+import static org.apache.camel.diagram.RouteDiagramLayoutEngine.SCOPE_BOX_PAD;
+import static 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.DiagramColors.*;
+
+public class RouteDiagramWidget implements Widget {
+
+    private static final int Y_SCALE = 20;
+    private static final int MIN_BOX_WIDTH = 16;
+    private static final int X_DIVISOR = 15;
+    private static final int MAX_WRAP_LINES = 3;
+
+    // Dashed scope box characters
+    private static final char SCOPE_H = '╌';
+    private static final char SCOPE_V = '╎';
+
+    private final LayoutRoute layoutRoute;
+    private final int nodeWidth;
+    private final int boxWidth;
+    private final int selectedNodeIndex;
+    private final int scrollX;
+    private final int scrollY;
+    private final boolean showMetrics;
+
+    private final List<EipNodeBox> nodeBoxes = new ArrayList<>();
+
+    public record EipNodeBox(String nodeId, String type, int startRow, int 
endRow, int startCol, int endCol,
+            LayoutNode layoutNode) {
+    }
+
+    public RouteDiagramWidget(
+                              LayoutRoute layoutRoute, int nodeWidth,
+                              int selectedNodeIndex, int scrollX, int scrollY,
+                              boolean showMetrics) {
+        this.layoutRoute = layoutRoute;
+        this.nodeWidth = nodeWidth;
+        this.boxWidth = Math.max(MIN_BOX_WIDTH, nodeWidth / X_DIVISOR);
+        this.selectedNodeIndex = selectedNodeIndex;
+        this.scrollX = scrollX;
+        this.scrollY = scrollY;
+        this.showMetrics = showMetrics;
+    }
+
+    public List<EipNodeBox> getNodeBoxes() {
+        return nodeBoxes;
+    }
+
+    @Override
+    public void render(Rect area, Buffer buffer) {
+        nodeBoxes.clear();
+
+        // Route label
+        int labelRow = toRow(layoutRoute.labelY);
+        String label = layoutRoute.routeId;
+        if (layoutRoute.source != null && !layoutRoute.source.isEmpty()) {
+            label += " (" + layoutRoute.source + ")";
+        }
+        writeText(buffer, area, labelRow, toCol(PADDING), label, 
ROUTE_ID_STYLE);
+
+        // Scope boxes (behind everything)
+        for (LayoutNode ln : layoutRoute.nodes) {
+            if (ln.treeNode != null && 
RouteDiagramLayoutEngine.hasScope(ln.treeNode)) {
+                drawScopeBox(buffer, area, ln);
+            }
+        }
+
+        // Edges
+        for (LayoutNode ln : layoutRoute.nodes) {
+            if (ln.parentNode != null) {
+                if (ln.connectFromMerge) {
+                    drawMergeArrow(buffer, area, ln);
+                } else {
+                    drawArrow(buffer, area, ln.parentNode, ln);
+                }
+            }
+        }
+
+        // Nodes (on top)
+        for (LayoutNode ln : layoutRoute.nodes) {
+            drawNode(buffer, area, ln);
+        }
+    }
+
+    public int getTotalRows() {
+        return toRow(layoutRoute.maxY) + 10;
+    }
+
+    public int getTotalCols() {
+        return toCol(layoutRoute.maxX + PADDING) + boxWidth + 4;
+    }
+
+    private void drawNode(Buffer buffer, Rect area, LayoutNode node) {
+        int col = toCol(node.x);
+        int row = toRow(node.y);
+        int innerWidth = boxWidth - 4;
+        List<String> lines = rewrapText(node, innerWidth);
+        int height = 2 + lines.size();
+
+        int nodeIdx = nodeBoxes.size();
+        boolean selected = nodeIdx == selectedNodeIndex;
+
+        Color eipColor = getEipColor(node.type);
+        Style borderStyle = Style.EMPTY.fg(eipColor);
+        if (selected) {
+            borderStyle = borderStyle.patch(SELECTION_STYLE);
+        }
+
+        // Top border
+        setChar(buffer, area, row, col, TL, borderStyle);
+        for (int c = col + 1; c < col + boxWidth - 1; c++) {
+            setChar(buffer, area, row, c, H, borderStyle);
+        }
+        setChar(buffer, area, row, col + boxWidth - 1, TR, borderStyle);
+
+        // Bottom border
+        int bottom = row + height - 1;
+        setChar(buffer, area, bottom, col, BL, borderStyle);
+        for (int c = col + 1; c < col + boxWidth - 1; c++) {
+            setChar(buffer, area, bottom, c, H, borderStyle);
+        }
+        setChar(buffer, area, bottom, col + boxWidth - 1, BR, borderStyle);
+
+        // Content rows
+        for (int i = 0; i < lines.size(); i++) {
+            int r = row + 1 + i;
+            setChar(buffer, area, r, col, V, borderStyle);
+            setChar(buffer, area, r, col + boxWidth - 1, V, borderStyle);
+
+            Style bgStyle = selected ? SELECTION_STYLE : Style.EMPTY;
+            for (int c = col + 1; c < col + boxWidth - 1; c++) {
+                setChar(buffer, area, r, c, ' ', bgStyle);
+            }
+
+            String text = lines.get(i);
+            if (text.length() > innerWidth) {
+                text = text.substring(0, Math.max(1, innerWidth - 3)) + "...";
+            }
+            int textCol = col + 2 + Math.max(0, (innerWidth - text.length()) / 
2);
+
+            // First line: [type] tag with EIP color, rest: label text
+            if (i == 0) {
+                writeText(buffer, area, r, textCol, text, 
style(Style.EMPTY.fg(eipColor).bold(), selected));
+            } else {
+                writeText(buffer, area, r, textCol, text, 
style(FROM_LABEL_STYLE, selected));
+            }
+        }
+
+        nodeBoxes.add(new EipNodeBox(node.id, node.type, row, row + height - 
1, col, col + boxWidth - 1, node));
+    }
+
+    private void drawArrow(Buffer buffer, Rect area, LayoutNode from, 
LayoutNode to) {
+        int fromCx = centerCol(from);
+        int fromBottom = toRow(from.y) + boxHeight(from);
+        int toCx = centerCol(to);
+        int toTop = getTopRow(to);
+
+        StatInfo stat = resolveStatInfo(to);
+        long total = stat != null ? stat.exchangesTotal : 0;
+        boolean dashed = showMetrics && total == 0;
+
+        drawArrowPath(buffer, area, fromCx, fromBottom, toCx, toTop, dashed);
+        drawCounters(buffer, area, toCx, toTop, stat);
+    }
+
+    private void drawMergeArrow(Buffer buffer, Rect area, LayoutNode to) {
+        int fromCx = toCol(to.mergeCx);
+        int fromRow = toRow(to.mergeY);
+        int toCx = centerCol(to);
+        int toTop = getTopRow(to);
+
+        StatInfo stat = showMetrics && to.treeNode != null ? 
to.treeNode.info.stat : null;
+        long total = stat != null ? stat.exchangesTotal : 0;
+        boolean dashed = showMetrics && total == 0;
+
+        drawArrowPath(buffer, area, fromCx, fromRow, toCx, toTop, dashed);
+        drawCounters(buffer, area, toCx, toTop, stat);
+    }
+
+    private void drawArrowPath(Buffer buffer, Rect area, int fromCx, int 
fromRow, int toCx, int toRow, boolean dashed) {
+        if (fromRow >= toRow) {
+            return;
+        }
+
+        char vChar = dashed ? DASH_V : V;
+        char hChar = dashed ? DASH_H : H;
+        Style edgeStyle = dashed ? Style.EMPTY.fg(Color.DARK_GRAY) : 
Style.EMPTY.fg(Color.GRAY);
+
+        if (fromCx == toCx) {
+            for (int r = fromRow; r < toRow - 1; r++) {
+                setChar(buffer, area, r, fromCx, vChar, edgeStyle);
+            }
+            setChar(buffer, area, toRow - 1, toCx, ARROW, edgeStyle);
+        } else {
+            int midRow = fromRow + (toRow - fromRow) / 2;
+
+            for (int r = fromRow; r < midRow; r++) {
+                setChar(buffer, area, r, fromCx, vChar, edgeStyle);
+            }
+
+            int minC = Math.min(fromCx, toCx);
+            int maxC = Math.max(fromCx, toCx);
+            for (int c = minC; c <= maxC; c++) {
+                setChar(buffer, area, midRow, c, hChar, edgeStyle);
+            }
+
+            setChar(buffer, area, midRow, fromCx, T_UP, edgeStyle);
+            setChar(buffer, area, midRow, toCx, T_DOWN, edgeStyle);
+
+            for (int r = midRow + 1; r < toRow - 1; r++) {
+                setChar(buffer, area, r, toCx, vChar, edgeStyle);
+            }
+            setChar(buffer, area, toRow - 1, toCx, ARROW, edgeStyle);
+        }
+    }
+
+    private void drawCounters(Buffer buffer, Rect area, int toCx, int toTop, 
StatInfo stat) {
+        if (!showMetrics || stat == null) {
+            return;
+        }
+        long total = stat.exchangesTotal;
+        long failed = stat.exchangesFailed;
+        long ok = total - failed;
+        if (ok > 0) {
+            String okStr = String.valueOf(ok);
+            writeText(buffer, area, toTop - 1, toCx + 2, okStr, 
METRICS_OK_STYLE);
+        }
+        if (failed > 0) {
+            String failStr = String.valueOf(failed);
+            int col = toCx - 1 - failStr.length();
+            writeText(buffer, area, toTop - 1, col, failStr, 
METRICS_FAIL_STYLE);
+        }
+    }
+
+    private void drawScopeBox(Buffer buffer, Rect area, LayoutNode scopeNode) {
+        TreeNode tn = scopeNode.treeNode;
+        Bounds bounds = new Bounds(
+                scopeNode.x, scopeNode.y,
+                scopeNode.x + nodeWidth, scopeNode.y + scopeNode.height);
+        for (TreeNode child : tn.children) {
+            RouteDiagramLayoutEngine.expandBoundsForBox(child, bounds, 
nodeWidth);
+        }
+
+        int col1 = toCol(bounds.minX - SCOPE_BOX_PAD);
+        int row1 = toRow(bounds.minY - SCOPE_BOX_PAD);
+        int col2 = toCol(bounds.maxX + SCOPE_BOX_PAD);
+        int row2 = toRow(bounds.maxY + SCOPE_BOX_PAD);
+
+        Color scopeColor = getEipColor(scopeNode.type);
+        Style scopeStyle = Style.EMPTY.fg(scopeColor).dim();
+
+        for (int c = col1; c <= col2; c++) {
+            setChar(buffer, area, row1, c, SCOPE_H, scopeStyle);
+            setChar(buffer, area, row2, c, SCOPE_H, scopeStyle);
+        }
+        for (int r = row1 + 1; r < row2; r++) {
+            setChar(buffer, area, r, col1, SCOPE_V, scopeStyle);
+            setChar(buffer, area, r, col2, SCOPE_V, scopeStyle);
+        }
+    }
+
+    private StatInfo resolveStatInfo(LayoutNode to) {
+        if (!showMetrics || to.treeNode == null) {
+            return null;
+        }
+        StatInfo stat = to.treeNode.info.stat;
+        if (BRANCH_CHILD_TYPES.contains(to.type) && 
!to.treeNode.children.isEmpty()) {
+            stat = to.treeNode.children.get(0).info.stat;
+        }
+        return stat;
+    }
+
+    private int getTopRow(LayoutNode node) {
+        if (node.treeNode != null && 
RouteDiagramLayoutEngine.hasScope(node.treeNode)) {
+            return toRow(node.y - SCOPE_BOX_PAD);
+        }
+        return toRow(node.y);
+    }
+
+    private int centerCol(LayoutNode node) {
+        return toCol(node.x + nodeWidth / 2);
+    }
+
+    private int boxHeight(LayoutNode node) {
+        return 2 + rewrapText(node, boxWidth - 4).size();
+    }
+
+    private List<String> rewrapText(LayoutNode node, int maxWidth) {
+        String label = String.join("", node.wrappedLines);
+        return wrapText(label, maxWidth);
+    }
+
+    static List<String> wrapText(String text, int maxWidth) {
+        if (maxWidth <= 0 || text.length() <= maxWidth) {
+            return new ArrayList<>(List.of(text));
+        }
+
+        List<String> lines = new ArrayList<>();
+        String remaining = text;
+
+        while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
+            if (remaining.length() <= maxWidth) {
+                lines.add(remaining);
+                remaining = "";
+                break;
+            }
+
+            int breakAt = -1;
+            for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
+                char c = remaining.charAt(i);
+                if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' 
|| c == '&' || c == '?') {
+                    breakAt = i + 1;
+                }
+            }
+            if (breakAt <= 0) {
+                breakAt = maxWidth;
+            }
+
+            lines.add(remaining.substring(0, breakAt).stripTrailing());
+            remaining = remaining.substring(breakAt).stripLeading();
+        }
+
+        if (!remaining.isEmpty()) {
+            int lastIdx = lines.size() - 1;
+            String lastLine = lines.get(lastIdx);
+            String combined = lastLine + remaining;
+            lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth - 
3)) + "...");
+        }
+
+        return lines;
+    }
+
+    private void setChar(Buffer buffer, Rect area, int gridRow, int gridCol, 
char ch, Style style) {
+        int x = area.x() + gridCol - scrollX;
+        int y = area.y() + gridRow - scrollY;
+        if (x >= area.left() && x < area.right() && y >= area.top() && y < 
area.bottom()) {
+            buffer.setString(x, y, String.valueOf(ch), style);
+        }
+    }
+
+    private void writeText(Buffer buffer, Rect area, int gridRow, int gridCol, 
String text, Style style) {
+        int x = area.x() + gridCol - scrollX;
+        int y = area.y() + gridRow - scrollY;
+        if (y >= area.top() && y < area.bottom() && x < area.right()) {
+            int startIdx = 0;
+            if (x < area.left()) {
+                startIdx = area.left() - x;
+                x = area.left();
+            }
+            if (startIdx < text.length()) {
+                int maxLen = area.right() - x;
+                String visible = text.substring(startIdx, 
Math.min(text.length(), startIdx + maxLen));
+                buffer.setString(x, y, visible, style);
+            }
+        }
+    }
+
+    private Style style(Style base, boolean selected) {
+        return selected ? base.patch(SELECTION_STYLE) : base;
+    }
+
+    private int toCol(int pixelX) {
+        if (nodeWidth == 0) {
+            return 0;
+        }
+        return pixelX * boxWidth / nodeWidth;
+    }
+
+    private int toRow(int pixelY) {
+        return pixelY / Y_SCALE;
+    }
+}


Reply via email to