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 e7242e6f72eb9ad2728c5a992219bd8121c57ff8
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Jun 3 19:41:23 2026 +0200

    CAMEL-23672: camel-tui - Native topology diagram renderer bypassing ASCII 
art
    
    Adds TopologyDiagramWidget that renders directly to TamboUI Buffer using
    Unicode box-drawing characters, eliminating the char[][] grid intermediary.
    Batch loads topology + all route structures in a single background 
operation,
    caching route layouts for instant drill-down without additional IPC calls.
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
 .../jbang/core/commands/tui/DiagramSupport.java    | 230 +++++++++++-
 .../dsl/jbang/core/commands/tui/DiagramTab.java    |  34 +-
 .../core/commands/tui/diagram/DiagramColors.java   |  74 ++++
 .../tui/diagram/TopologyDiagramWidget.java         | 387 +++++++++++++++++++++
 4 files changed, 714 insertions(+), 11 deletions(-)

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 861b6c91d477..14c76243390b 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
@@ -93,6 +93,11 @@ class DiagramSupport {
     private int lastVisibleHeight;
     private int lastVisibleWidth;
 
+    // Native widget rendering data
+    private TopologyLayoutResult topologyLayout;
+    private int topologyNodeWidth;
+    private java.util.Map<String, RouteDiagramLayoutEngine.LayoutRoute> 
routeLayouts = Collections.emptyMap();
+
     List<String> getLines() {
         return lines;
     }
@@ -170,9 +175,44 @@ class DiagramSupport {
     }
 
     boolean hasDiagramData() {
+        if (topologyLayout != null || !routeLayouts.isEmpty()) {
+            return true;
+        }
         return diagramTextMode ? !lines.isEmpty() : fullImageData != null;
     }
 
+    boolean hasNativeLayout() {
+        return topologyLayout != null;
+    }
+
+    TopologyLayoutResult getTopologyLayout() {
+        return topologyLayout;
+    }
+
+    int getTopologyNodeWidth() {
+        return topologyNodeWidth;
+    }
+
+    RouteDiagramLayoutEngine.LayoutRoute getRouteLayout(String routeId) {
+        return routeLayouts.get(routeId);
+    }
+
+    int getScrollX() {
+        return scrollX;
+    }
+
+    int getScrollY() {
+        return scrollY;
+    }
+
+    ScrollbarState getVScrollState() {
+        return vScrollState;
+    }
+
+    ScrollbarState getHScrollState() {
+        return hScrollState;
+    }
+
     ImageData getFullImageData() {
         return fullImageData;
     }
@@ -265,6 +305,9 @@ class DiagramSupport {
         nodeBoxes = Collections.emptyList();
         topologyNodes = Collections.emptyList();
         topologyEdges = Collections.emptyList();
+        topologyLayout = null;
+        topologyNodeWidth = 0;
+        routeLayouts = Collections.emptyMap();
         selectedNodeIndex = -1;
         scrollY = 0;
         scrollX = 0;
@@ -453,7 +496,100 @@ class DiagramSupport {
         }
     }
 
-    // ---- Rendering ----
+    private List<TopologyAsciiRenderer.NodeBox> computeNodeBoxes(
+            TopologyLayoutResult result, int nodeW, boolean metrics) {
+        int bw = Math.max(16, nodeW / 15);
+        List<TopologyAsciiRenderer.NodeBox> boxes = new ArrayList<>();
+        for (TopologyLayoutNode node : result.nodes) {
+            int col = nodeW == 0 ? 0 : node.x * bw / nodeW;
+            int row = node.y / 20;
+            boolean ext = "external-in".equals(node.nodeType) || 
"external-out".equals(node.nodeType);
+            int contentLines;
+            if (ext) {
+                contentLines = 1;
+                if (metrics && node.exchangesTotal > 0) {
+                    contentLines++;
+                }
+            } else {
+                contentLines = 3; // routeId + from (2 lines reserved)
+                if (metrics) {
+                    contentLines++;
+                }
+                contentLines = Math.min(contentLines, 4);
+            }
+            int height = 2 + contentLines;
+            boxes.add(new TopologyAsciiRenderer.NodeBox(
+                    node.routeId, row, row + height - 1, col, col + bw - 1, 
node.layer));
+        }
+        return boxes;
+    }
+
+    void renderNativeDiagram(Frame frame, Rect area, String title, boolean 
metrics) {
+        Block block = Block.builder()
+                .borderType(BorderType.ROUNDED)
+                .title(title)
+                .build();
+        frame.renderWidget(block, area);
+
+        Rect inner = block.inner(area);
+
+        var widget = new 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyDiagramWidget(
+                topologyLayout, topologyNodeWidth, selectedNodeIndex, scrollX, 
scrollY, metrics, showDescription);
+
+        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);
+
+        // Re-create widget with clamped scroll
+        var finalWidget = new 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyDiagramWidget(
+                topologyLayout, topologyNodeWidth, selectedNodeIndex, scrollX, 
scrollY, metrics, showDescription);
+
+        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));
+
+        // Update nodeBoxes from widget
+        List<TopologyAsciiRenderer.NodeBox> widgetBoxes = new ArrayList<>();
+        for (var nb : finalWidget.getNodeBoxes()) {
+            widgetBoxes.add(new TopologyAsciiRenderer.NodeBox(
+                    nb.routeId(), nb.startRow(), nb.endRow(), nb.startCol(), 
nb.endCol(), nb.layer()));
+        }
+        if (!widgetBoxes.isEmpty()) {
+            nodeBoxes = widgetBoxes;
+        }
+
+        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) {
         Block block = Block.builder()
@@ -650,6 +786,98 @@ class DiagramSupport {
         renderRoutes(ctx, textMode, routes, false, nodeIds, hlStyle);
     }
 
+    void loadAllDiagramsInBackground(
+            MonitorContext ctx, String pid, boolean metrics, boolean external) 
{
+        // Fetch topology
+        JsonObject topoJson = requestRouteTopology(ctx, pid, external);
+        // Fetch all route structures
+        JsonObject routeJson = requestRouteStructure(ctx, pid);
+
+        TopologyLayoutResult topoResult = null;
+        int nodeW = 0;
+        List<TopologyLayoutNode> topoNodes = Collections.emptyList();
+        List<TopologyLayoutEdge> topoEdges = Collections.emptyList();
+
+        if (topoJson != null) {
+            List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(topoJson);
+            List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(topoJson);
+            if (external) {
+                TopologyHelper.addExternalEndpoints(nodes, edges, topoJson);
+            }
+            if (!nodes.isEmpty()) {
+                TopologyLayoutEngine engine = new TopologyLayoutEngine();
+                topoResult = engine.layout(nodes, edges);
+                nodeW = engine.getNodeWidth();
+                topoNodes = topoResult.nodes;
+                topoEdges = topoResult.edges;
+            }
+        }
+
+        java.util.Map<String, RouteDiagramLayoutEngine.LayoutRoute> routeMap = 
new java.util.LinkedHashMap<>();
+        if (routeJson != null) {
+            List<RouteDiagramLayoutEngine.RouteInfo> routes = 
RouteDiagramHelper.parseRoutes(routeJson);
+            RouteDiagramLayoutEngine.NodeLabelMode labelMode = showDescription
+                    ? RouteDiagramLayoutEngine.NodeLabelMode.DESCRIPTION
+                    : RouteDiagramLayoutEngine.NodeLabelMode.CODE;
+            RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
+                    RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, 
RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE,
+                    labelMode);
+            int currentY = RouteDiagramLayoutEngine.PADDING;
+            for (RouteDiagramLayoutEngine.RouteInfo r : routes) {
+                RouteDiagramLayoutEngine.LayoutRoute lr = 
engine.layoutRoute(r, currentY);
+                routeMap.put(r.routeId, lr);
+                currentY = lr.maxY + RouteDiagramLayoutEngine.V_GAP;
+            }
+        }
+
+        // Apply results on render thread
+        TopologyLayoutResult finalTopoResult = topoResult;
+        int finalNodeW = nodeW;
+        List<TopologyLayoutNode> finalTopoNodes = topoNodes;
+        List<TopologyLayoutEdge> finalTopoEdges = topoEdges;
+
+        if (ctx.runner == null) {
+            return;
+        }
+        ctx.runner.runOnRenderThread(() -> {
+            topologyLayout = finalTopoResult;
+            topologyNodeWidth = finalNodeW;
+            topologyNodes = finalTopoNodes;
+            topologyEdges = finalTopoEdges;
+            routeLayouts = routeMap;
+
+            // Preserve selection
+            String prevRouteId = getSelectedRouteId();
+            if (prevRouteId == null && pendingSelectionRouteId != null) {
+                prevRouteId = pendingSelectionRouteId;
+            }
+            pendingSelectionRouteId = null;
+
+            if (finalTopoResult != null) {
+                List<TopologyAsciiRenderer.NodeBox> boxes
+                        = computeNodeBoxes(finalTopoResult, finalNodeW, 
metrics);
+                nodeBoxes = boxes;
+
+                if (prevRouteId != null && !boxes.isEmpty()) {
+                    int newIdx = -1;
+                    for (int i = 0; i < boxes.size(); i++) {
+                        if (prevRouteId.equals(boxes.get(i).routeId())) {
+                            newIdx = i;
+                            break;
+                        }
+                    }
+                    selectedNodeIndex = newIdx >= 0 ? newIdx : 0;
+                } else if (!boxes.isEmpty() && selectedNodeIndex < 0) {
+                    selectedNodeIndex = findTopLeftNode(boxes);
+                } else if (boxes.isEmpty()) {
+                    selectedNodeIndex = -1;
+                }
+            }
+
+            showDiagram = true;
+        });
+    }
+
     void loadRouteDiagramInBackground(
             MonitorContext ctx, String pid, boolean textMode,
             String routeId, boolean metrics) {
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 9949b560ff43..80128534e8c3 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
@@ -153,7 +153,7 @@ class DiagramTab implements MonitorTab {
     @Override
     public void onTabSelected() {
         if (!diagram.isShowDiagram()) {
-            diagram.toggleTextDiagram(this::loadDiagram);
+            loadDiagram();
         }
     }
 
@@ -182,15 +182,29 @@ class DiagramTab implements MonitorTab {
             }
 
             String selectedRouteId = topologyMode ? 
diagram.getSelectedRouteId() : drillDownRouteId;
-            if (selectedRouteId != null && area.width() > 60) {
-                int panelWidth = 30;
-                List<Rect> hChunks = Layout.horizontal()
-                        .constraints(Constraint.length(panelWidth), 
Constraint.fill())
-                        .split(area);
-                renderInfoPanel(frame, hChunks.get(0), info, selectedRouteId);
-                diagram.renderDiagram(frame, hChunks.get(1), title);
+
+            if (topologyMode && diagram.hasNativeLayout()) {
+                if (selectedRouteId != null && area.width() > 60) {
+                    int panelWidth = 30;
+                    List<Rect> hChunks = Layout.horizontal()
+                            .constraints(Constraint.length(panelWidth), 
Constraint.fill())
+                            .split(area);
+                    renderInfoPanel(frame, hChunks.get(0), info, 
selectedRouteId);
+                    diagram.renderNativeDiagram(frame, hChunks.get(1), title, 
diagramMetrics);
+                } else {
+                    diagram.renderNativeDiagram(frame, area, title, 
diagramMetrics);
+                }
             } else {
-                diagram.renderDiagram(frame, area, title);
+                if (selectedRouteId != null && area.width() > 60) {
+                    int panelWidth = 30;
+                    List<Rect> hChunks = Layout.horizontal()
+                            .constraints(Constraint.length(panelWidth), 
Constraint.fill())
+                            .split(area);
+                    renderInfoPanel(frame, hChunks.get(0), info, 
selectedRouteId);
+                    diagram.renderDiagram(frame, hChunks.get(1), title);
+                } else {
+                    diagram.renderDiagram(frame, area, title);
+                }
             }
             return;
         }
@@ -378,7 +392,7 @@ class DiagramTab implements MonitorTab {
             try {
                 if (topologyMode) {
                     diagram.setTopologyMode(true);
-                    diagram.loadTopologyDiagramInBackground(ctx, pid, true, 
showMetrics, external);
+                    diagram.loadAllDiagramsInBackground(ctx, pid, showMetrics, 
external);
                 } else {
                     diagram.setTopologyMode(false);
                     diagram.loadRouteDiagramInBackground(ctx, pid, true, 
drillDownRouteId, showMetrics);
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
new file mode 100644
index 000000000000..a13f5579b5c9
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java
@@ -0,0 +1,74 @@
+/*
+ * 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 dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+
+final class DiagramColors {
+
+    static final Color OK_COLOR = Color.GREEN;
+    static final Color FAIL_COLOR = Color.LIGHT_RED;
+    static final Color EXTERNAL_COLOR = Color.CYAN;
+
+    static final Style BORDER_STYLE = Style.EMPTY.fg(Color.WHITE);
+    static final Style DASHED_BORDER_STYLE = Style.EMPTY.fg(Color.CYAN);
+    static final Style SELECTION_STYLE = Style.EMPTY.bg(Color.DARK_GRAY);
+    static final Style ROUTE_ID_STYLE = Style.EMPTY.fg(Color.WHITE).bold();
+    static final Style FROM_LABEL_STYLE = Style.EMPTY.fg(Color.GRAY);
+    static final Style METRICS_OK_STYLE = Style.EMPTY.fg(Color.GREEN);
+    static final Style METRICS_FAIL_STYLE = 
Style.EMPTY.fg(Color.LIGHT_RED).bold();
+
+    // Unicode box-drawing characters
+    static final char H = '─';
+    static final char V = '│';
+    static final char TL = '┌';
+    static final char TR = '┐';
+    static final char BL = '└';
+    static final char BR = '┘';
+    static final char T_DOWN = '┬';
+    static final char T_UP = '┴';
+    static final char CROSS = '┼';
+    static final char ARROW = '▼';
+    static final char DASH_V = '┆';
+    static final char DASH_H = '┄';
+
+    private DiagramColors() {
+    }
+
+    static Color getEipColor(String type) {
+        if (type == null) {
+            return Color.GRAY;
+        }
+        return switch (type) {
+            case "from" -> Color.GREEN;
+            case "to", "toD", "wireTap", "enrich", "pollEnrich" -> Color.CYAN;
+            case "choice", "when", "otherwise" -> Color.YELLOW;
+            case "marshal", "unmarshal", "transform", "setBody", "setHeader", 
"setProperty",
+                    "convertBodyTo", "removeHeader", "removeHeaders", 
"removeProperty", "removeProperties" ->
+                Color.CYAN;
+            case "bean", "process", "log", "script", "delay" -> Color.MAGENTA;
+            case "filter", "split", "aggregate", "multicast", "recipientList",
+                    "routingSlip", "dynamicRouter", "loadBalance",
+                    "circuitBreaker", "saga", "doTry", "doCatch", "doFinally",
+                    "onException", "onCompletion", "intercept",
+                    "loop", "resequence", "throttle", "kamelet", "pipeline", 
"threads" ->
+                Color.rgb(0x89, 0x57, 0xE5);
+            default -> Color.GRAY;
+        };
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
new file mode 100644
index 000000000000..8fb84a3e9f05
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
@@ -0,0 +1,387 @@
+/*
+ * 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.TopologyLayoutEngine.TopologyLayoutEdge;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
+
+import static 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.DiagramColors.*;
+
+public class TopologyDiagramWidget 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;
+
+    private final TopologyLayoutResult layout;
+    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 boolean showDescription;
+
+    private final List<NodeBox> nodeBoxes = new ArrayList<>();
+
+    public record NodeBox(String routeId, int startRow, int endRow, int 
startCol, int endCol, int layer) {
+    }
+
+    public TopologyDiagramWidget(
+                                 TopologyLayoutResult layout, int nodeWidth,
+                                 int selectedNodeIndex, int scrollX, int 
scrollY,
+                                 boolean showMetrics, boolean showDescription) 
{
+        this.layout = layout;
+        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;
+        this.showDescription = showDescription;
+    }
+
+    public List<NodeBox> getNodeBoxes() {
+        return nodeBoxes;
+    }
+
+    @Override
+    public void render(Rect area, Buffer buffer) {
+        nodeBoxes.clear();
+
+        for (TopologyLayoutEdge edge : layout.edges) {
+            if (!edge.selfLoop) {
+                drawEdge(buffer, area, edge);
+            }
+        }
+
+        for (TopologyLayoutEdge edge : layout.edges) {
+            if (edge.selfLoop) {
+                drawSelfLoop(buffer, area, edge);
+            }
+        }
+
+        for (TopologyLayoutNode node : layout.nodes) {
+            drawNode(buffer, area, node);
+        }
+    }
+
+    public int getTotalRows() {
+        return toRow(layout.totalHeight) + 10;
+    }
+
+    public int getTotalCols() {
+        return toCol(layout.totalWidth) + boxWidth + 4;
+    }
+
+    private void drawNode(Buffer buffer, Rect area, TopologyLayoutNode node) {
+        int col = toCol(node.x);
+        int row = toRow(node.y);
+
+        boolean ext = isExternal(node);
+
+        String line1;
+        if (ext) {
+            line1 = node.from;
+        } else if (showDescription && node.description != null && 
!node.description.isBlank()) {
+            line1 = node.description;
+        } else {
+            line1 = node.routeId;
+        }
+
+        List<String> lines = new ArrayList<>(wrapText(line1, boxWidth - 4));
+        if (!ext && !showDescription) {
+            String line2 = "(" + node.from + ")";
+            List<String> fromLines = wrapText(line2, boxWidth - 4);
+            lines.addAll(fromLines);
+            if (fromLines.size() < 2) {
+                lines.add("");
+            }
+        }
+
+        if (showMetrics) {
+            if (node.exchangesTotal > 0 || node.exchangesFailed > 0) {
+                long ok = node.exchangesTotal - node.exchangesFailed;
+                StringBuilder sb = new StringBuilder();
+                if (ok > 0) {
+                    sb.append(ok);
+                }
+                if (node.exchangesFailed > 0) {
+                    if (!sb.isEmpty()) {
+                        sb.append("/");
+                    }
+                    sb.append(node.exchangesFailed).append("!");
+                }
+                lines.add(sb.toString());
+            } else if (!ext) {
+                lines.add("");
+            }
+        }
+
+        while (lines.size() > MAX_WRAP_LINES + 1) {
+            lines.remove(lines.size() - 1);
+        }
+
+        int height = 2 + lines.size();
+        int nodeIdx = nodeBoxes.size();
+        boolean selected = nodeIdx == selectedNodeIndex;
+
+        char hChar = ext ? DASH_H : H;
+        char vChar = ext ? DASH_V : V;
+        Style borderStyle = ext ? DASHED_BORDER_STYLE : BORDER_STYLE;
+        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, hChar, 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, hChar, borderStyle);
+        }
+        setChar(buffer, area, bottom, col + boxWidth - 1, BR, borderStyle);
+
+        // Content rows
+        int innerWidth = boxWidth - 4;
+        for (int i = 0; i < lines.size(); i++) {
+            int r = row + 1 + i;
+            setChar(buffer, area, r, col, vChar, borderStyle);
+            setChar(buffer, area, r, col + boxWidth - 1, vChar, borderStyle);
+
+            // Clear interior
+            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);
+
+            // Choose style based on content type
+            if (ext && i == 0) {
+                writeText(buffer, area, r, textCol, text, 
style(DASHED_BORDER_STYLE, selected));
+            } else if (showMetrics && i == lines.size() - 1 && 
node.exchangesTotal > 0) {
+                drawMetricsLine(buffer, area, r, textCol, text, node, 
selected);
+            } else if (i == 0 && !ext) {
+                writeText(buffer, area, r, textCol, text, 
style(ROUTE_ID_STYLE, selected));
+            } else {
+                writeText(buffer, area, r, textCol, text, 
style(FROM_LABEL_STYLE, selected));
+            }
+        }
+
+        nodeBoxes.add(new NodeBox(node.routeId, row, row + height - 1, col, 
col + boxWidth - 1, node.layer));
+    }
+
+    private void drawMetricsLine(
+            Buffer buffer, Rect area, int row, int col, String text,
+            TopologyLayoutNode node, boolean selected) {
+        long ok = node.exchangesTotal - node.exchangesFailed;
+        if (ok > 0 && node.exchangesFailed > 0) {
+            String okStr = String.valueOf(ok);
+            String failStr = node.exchangesFailed + "!";
+            writeText(buffer, area, row, col, okStr, style(METRICS_OK_STYLE, 
selected));
+            int slashCol = col + okStr.length();
+            writeText(buffer, area, row, slashCol, "/", 
style(Style.EMPTY.fg(Color.GRAY), selected));
+            writeText(buffer, area, row, slashCol + 1, failStr, 
style(METRICS_FAIL_STYLE, selected));
+        } else if (ok > 0) {
+            writeText(buffer, area, row, col, text, style(METRICS_OK_STYLE, 
selected));
+        } else if (node.exchangesFailed > 0) {
+            writeText(buffer, area, row, col, text, style(METRICS_FAIL_STYLE, 
selected));
+        }
+    }
+
+    private void drawEdge(Buffer buffer, Rect area, TopologyLayoutEdge edge) {
+        int fromCx = toCol(edge.from.x + edge.from.width / 2);
+        int fromBottom = toRow(edge.from.y) + boxHeight(edge.from);
+        int toCx = toCol(edge.to.x + edge.to.width / 2);
+        int toTop = toRow(edge.to.y);
+
+        if (fromBottom >= toTop) {
+            return;
+        }
+
+        boolean dashed = isExternal(edge.from) || isExternal(edge.to);
+        char vChar = dashed ? DASH_V : V;
+        char hChar = dashed ? DASH_H : H;
+        Style edgeStyle = dashed ? DASHED_BORDER_STYLE : 
Style.EMPTY.fg(Color.GRAY);
+
+        if (fromCx == toCx) {
+            for (int r = fromBottom; r < toTop - 1; r++) {
+                plotLine(buffer, area, r, fromCx, vChar, edgeStyle);
+            }
+            setChar(buffer, area, toTop - 1, toCx, ARROW, edgeStyle);
+        } else {
+            int midRow = fromBottom + (toTop - fromBottom) / 2;
+
+            for (int r = fromBottom; r < midRow; r++) {
+                plotLine(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++) {
+                plotLine(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 < toTop - 1; r++) {
+                plotLine(buffer, area, r, toCx, vChar, edgeStyle);
+            }
+            setChar(buffer, area, toTop - 1, toCx, ARROW, edgeStyle);
+        }
+    }
+
+    private void drawSelfLoop(Buffer buffer, Rect area, TopologyLayoutEdge 
edge) {
+        int col = toCol(edge.from.x) + boxWidth;
+        int topRow = toRow(edge.from.y) + 1;
+        int botRow = topRow + 2;
+
+        Style s = Style.EMPTY.fg(Color.GRAY);
+        for (int c = col; c < col + 3; c++) {
+            setChar(buffer, area, topRow, c, H, s);
+            setChar(buffer, area, botRow, c, H, s);
+        }
+        setChar(buffer, area, topRow + 1, col + 2, V, s);
+        setChar(buffer, area, topRow, col + 2, TR, s);
+        setChar(buffer, area, botRow, col + 2, BR, s);
+    }
+
+    private int boxHeight(TopologyLayoutNode node) {
+        if (isExternal(node)) {
+            int lines = 1;
+            if (showMetrics && node.exchangesTotal > 0) {
+                lines++;
+            }
+            return 2 + lines;
+        }
+        int lines = 3;
+        if (showMetrics) {
+            lines++;
+        }
+        return 2 + Math.min(lines, MAX_WRAP_LINES + 1);
+    }
+
+    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 plotLine(Buffer buffer, Rect area, int gridRow, int gridCol, 
char ch, Style style) {
+        setChar(buffer, area, gridRow, gridCol, 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;
+    }
+
+    private static boolean isExternal(TopologyLayoutNode node) {
+        return "external-in".equals(node.nodeType) || 
"external-out".equals(node.nodeType);
+    }
+
+    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;
+    }
+}

Reply via email to