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