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; + } +}
