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 b149b3057c52babb03d5b1d10828435c0f8e68f8 Author: Claus Ibsen <[email protected]> AuthorDate: Thu Jun 4 08:04:24 2026 +0200 CAMEL-23672: camel-tui - Diagram navigation improvements - Add Home/End keys to select first/last node in topology and route diagrams - Add 't' shortcut to jump directly back to topology from any route depth - Show linked route ID in diagram (↵ routeId) next to navigable nodes - Yellow styled route names in breadcrumb title matching the link indicator - Normalize diagram Y positions with 2 rows padding below title - Fix node selection using visual position instead of list order - Fix loading placeholder showing when diagram data is cached Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../jbang/core/commands/tui/DiagramSupport.java | 182 ++++++++++++++++----- .../dsl/jbang/core/commands/tui/DiagramTab.java | 73 +++++++-- .../commands/tui/diagram/RouteDiagramWidget.java | 23 ++- 3 files changed, 208 insertions(+), 70 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 ad352e943e6f..fcfcbb61bfc7 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 @@ -19,9 +19,11 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; @@ -38,6 +40,7 @@ import dev.tamboui.text.Text; import dev.tamboui.tui.event.KeyEvent; import dev.tamboui.widgets.block.Block; import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.block.Title; import dev.tamboui.widgets.paragraph.Paragraph; import dev.tamboui.widgets.scrollbar.Scrollbar; import dev.tamboui.widgets.scrollbar.ScrollbarState; @@ -434,6 +437,38 @@ class DiagramSupport { } } + void selectFirstNode() { + if (nodeBoxes.isEmpty()) { + return; + } + int bestIdx = 0; + for (int i = 1; i < nodeBoxes.size(); i++) { + var best = nodeBoxes.get(bestIdx); + var nb = nodeBoxes.get(i); + if (nb.startRow() < best.startRow() + || (nb.startRow() == best.startRow() && nb.startCol() < best.startCol())) { + bestIdx = i; + } + } + selectedNodeIndex = bestIdx; + } + + void selectLastNode() { + if (nodeBoxes.isEmpty()) { + return; + } + int bestIdx = 0; + for (int i = 1; i < nodeBoxes.size(); i++) { + var best = nodeBoxes.get(bestIdx); + var nb = nodeBoxes.get(i); + if (nb.startRow() > best.startRow() + || (nb.startRow() == best.startRow() && nb.startCol() > best.startCol())) { + bestIdx = i; + } + } + selectedNodeIndex = bestIdx; + } + void scrollToSelectedNode() { if (selectedNodeIndex < 0 || selectedNodeIndex >= nodeBoxes.size()) { return; @@ -627,15 +662,9 @@ class DiagramSupport { if (currentRouteId.equals(entry.getKey())) { continue; } - var lr = entry.getValue(); - if (!lr.nodes.isEmpty()) { - var firstNode = lr.nodes.get(0); - if ("from".equals(firstNode.type) && firstNode.treeNode != null) { - String fromBaseUri = getBaseUri(firstNode.treeNode.info); - if (baseUri.equals(fromBaseUri)) { - return entry.getKey(); - } - } + String fromBaseUri = findFromUri(entry.getValue()); + if (baseUri.equals(fromBaseUri)) { + return entry.getKey(); } } } @@ -738,6 +767,38 @@ class DiagramSupport { } } + void selectFirstEipNode() { + if (eipNodeBoxes.isEmpty()) { + return; + } + int bestIdx = 0; + for (int i = 1; i < eipNodeBoxes.size(); i++) { + var best = eipNodeBoxes.get(bestIdx); + var nb = eipNodeBoxes.get(i); + if (nb.startRow() < best.startRow() + || (nb.startRow() == best.startRow() && nb.startCol() < best.startCol())) { + bestIdx = i; + } + } + selectedEipNodeIndex = bestIdx; + } + + void selectLastEipNode() { + if (eipNodeBoxes.isEmpty()) { + return; + } + int bestIdx = 0; + for (int i = 1; i < eipNodeBoxes.size(); i++) { + var best = eipNodeBoxes.get(bestIdx); + var nb = eipNodeBoxes.get(i); + if (nb.startRow() > best.startRow() + || (nb.startRow() == best.startRow() && nb.startCol() > best.startCol())) { + bestIdx = i; + } + } + selectedEipNodeIndex = bestIdx; + } + void scrollToSelectedEipNode() { if (selectedEipNodeIndex < 0 || selectedEipNodeIndex >= eipNodeBoxes.size()) { return; @@ -762,18 +823,14 @@ class DiagramSupport { } /** - * Computes the set of base endpoint URIs that can be navigated to from the current route. Includes both "from" URIs - * of other routes (for "to" nodes) and "to" URIs that target this route (for "from" nodes). + * Computes a mapping from base endpoint URI to target route ID for navigable links from the current route. */ - private Set<String> computeLinkableEndpoints(String currentRouteId) { - Set<String> endpoints = new HashSet<>(); + private Map<String, String> computeLinkableEndpoints(String currentRouteId) { + Map<String, String> endpoints = new HashMap<>(); String currentFromUri = null; var currentLayout = routeLayouts.get(currentRouteId); - if (currentLayout != null && !currentLayout.nodes.isEmpty()) { - var fromNode = currentLayout.nodes.get(0); - if ("from".equals(fromNode.type) && fromNode.treeNode != null) { - currentFromUri = getBaseUri(fromNode.treeNode.info); - } + if (currentLayout != null) { + currentFromUri = findFromUri(currentLayout); } for (var entry : routeLayouts.entrySet()) { @@ -781,25 +838,18 @@ class DiagramSupport { continue; } var lr = entry.getValue(); - if (!lr.nodes.isEmpty()) { - // Add "from" URIs of other routes (linkable from "to" nodes) - var firstNode = lr.nodes.get(0); - if ("from".equals(firstNode.type) && firstNode.treeNode != null) { - String uri = getBaseUri(firstNode.treeNode.info); - if (uri != null) { - endpoints.add(uri); - } - } - // Add "to" URIs that target our "from" endpoint (linkable from "from" node) - if (currentFromUri != null) { - for (var node : lr.nodes) { - String type = node.type; - if (("to".equals(type) || "toD".equals(type) || "wireTap".equals(type)) - && node.treeNode != null) { - String uri = getBaseUri(node.treeNode.info); - if (currentFromUri.equals(uri)) { - endpoints.add(currentFromUri); - } + String fromUri = findFromUri(lr); + if (fromUri != null) { + endpoints.put(fromUri, entry.getKey()); + } + if (currentFromUri != null) { + for (var node : lr.nodes) { + String type = node.type; + if (("to".equals(type) || "toD".equals(type) || "wireTap".equals(type)) + && node.treeNode != null) { + String uri = getBaseUri(node.treeNode.info); + if (currentFromUri.equals(uri)) { + endpoints.put(currentFromUri, entry.getKey()); } } } @@ -808,18 +858,27 @@ class DiagramSupport { return endpoints; } + private static String findFromUri(RouteDiagramLayoutEngine.LayoutRoute lr) { + for (var node : lr.nodes) { + if ("from".equals(node.type) && node.treeNode != null) { + return getBaseUri(node.treeNode.info); + } + } + return null; + } + void renderNativeRouteDiagram( - Frame frame, Rect area, String title, boolean metrics, + Frame frame, Rect area, Line title, boolean metrics, String currentRouteId, RouteDiagramLayoutEngine.LayoutRoute routeLayout) { Block block = Block.builder() .borderType(BorderType.ROUNDED) - .title(title) + .title(Title.from(title)) .build(); frame.renderWidget(block, area); Rect inner = block.inner(area); int nw = RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH * RouteDiagramLayoutEngine.SCALE; - Set<String> linkable = computeLinkableEndpoints(currentRouteId); + Map<String, String> linkable = computeLinkableEndpoints(currentRouteId); var widget = new org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget( routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, metrics, linkable); @@ -1010,6 +1069,7 @@ class DiagramSupport { if (!nodes.isEmpty()) { TopologyLayoutEngine engine = new TopologyLayoutEngine(); topoResult = engine.layout(nodes, edges); + normalizeTopologyLayoutY(topoResult); nodeW = engine.getNodeWidth(); topoNodes = topoResult.nodes; topoEdges = topoResult.edges; @@ -1028,8 +1088,8 @@ class DiagramSupport { RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE, labelMode); for (RouteDiagramLayoutEngine.RouteInfo r : routes) { - RouteDiagramLayoutEngine.LayoutRoute lr - = engine.layoutRoute(r, RouteDiagramLayoutEngine.PADDING); + RouteDiagramLayoutEngine.LayoutRoute lr = engine.layoutRoute(r, 0); + normalizeRouteLayoutY(lr); routeMap.put(r.routeId, lr); } } @@ -1515,4 +1575,42 @@ class DiagramSupport { } } } + + private static void normalizeTopologyLayoutY(TopologyLayoutResult result) { + if (result.nodes.isEmpty()) { + return; + } + int minY = Integer.MAX_VALUE; + for (TopologyLayoutNode node : result.nodes) { + minY = Math.min(minY, node.y); + } + if (minY <= 0) { + return; + } + int shift = minY - 40; + for (TopologyLayoutNode node : result.nodes) { + node.y -= shift; + } + } + + private static void normalizeRouteLayoutY(RouteDiagramLayoutEngine.LayoutRoute lr) { + int minY = Integer.MAX_VALUE; + for (RouteDiagramLayoutEngine.LayoutNode ln : lr.nodes) { + if (!"route".equals(ln.type)) { + minY = Math.min(minY, ln.y); + } + } + if (minY <= 0 || minY == Integer.MAX_VALUE) { + return; + } + int shift = minY - 40; + for (RouteDiagramLayoutEngine.LayoutNode ln : lr.nodes) { + ln.y -= shift; + if (ln.connectFromMerge) { + ln.mergeY -= shift; + } + } + lr.labelY -= shift; + lr.maxY -= shift; + } } 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 ac80a3f54499..3cfa55e4fe7d 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 @@ -93,6 +93,16 @@ class DiagramTab implements MonitorTab { diagram.scrollToSelectedNode(); return true; } + if (ke.isHome()) { + diagram.selectFirstNode(); + diagram.scrollToSelectedNode(); + return true; + } + if (ke.isEnd()) { + diagram.selectLastNode(); + diagram.scrollToSelectedNode(); + return true; + } } // EIP node navigation in route drill-down mode @@ -117,6 +127,33 @@ class DiagramTab implements MonitorTab { diagram.scrollToSelectedEipNode(); return true; } + if (ke.isHome()) { + diagram.selectFirstEipNode(); + diagram.scrollToSelectedEipNode(); + return true; + } + if (ke.isEnd()) { + diagram.selectLastEipNode(); + diagram.scrollToSelectedEipNode(); + return true; + } + } + + // Jump back to topology from any depth + if (!topologyMode && diagram.isShowDiagram() && ke.isChar('t')) { + routeNavigationStack.clear(); + diagram.setPendingSelectionRouteId(drillDownRouteId); + drillDownRouteId = null; + topologyMode = true; + diagram.setTopologyMode(true); + diagram.setSelectedEipNodeIndex(-1); + diagram.resetScroll(); + if (diagram.hasNativeLayout()) { + return true; + } + diagram.endLoad(); + reloadDiagram(); + return true; } if (diagram.handleScrollKeys(ke)) { @@ -268,16 +305,10 @@ class DiagramTab implements MonitorTab { } if (diagram.isShowDiagram() && diagram.hasDiagramData()) { - String title; - if (topologyMode) { - title = " Topology "; - } else { - title = " Route [" + buildBreadcrumb() + "] "; - } - String selectedRouteId = topologyMode ? diagram.getSelectedRouteId() : drillDownRouteId; if (topologyMode && diagram.hasNativeLayout()) { + String title = " Topology "; if (selectedRouteId != null && area.width() > 60) { int panelWidth = 30; List<Rect> hChunks = Layout.horizontal() @@ -288,8 +319,10 @@ class DiagramTab implements MonitorTab { } else { diagram.renderNativeDiagram(frame, area, title, diagramMetrics); } + return; } else if (!topologyMode && drillDownRouteId != null && diagram.getRouteLayout(drillDownRouteId) != null) { + Line title = buildBreadcrumbTitle(); var routeLayout = diagram.getRouteLayout(drillDownRouteId); if (area.width() > 60) { int panelWidth = 30; @@ -302,8 +335,8 @@ class DiagramTab implements MonitorTab { } else { diagram.renderNativeRouteDiagram(frame, area, title, diagramMetrics, drillDownRouteId, routeLayout); } + return; } - return; } // Show placeholder when no diagram is loaded yet @@ -521,6 +554,7 @@ class DiagramTab implements MonitorTab { if (diagram.isShowDiagram()) { if (!topologyMode && !diagram.getEipNodeBoxes().isEmpty()) { hint(spans, "Esc", "back"); + hint(spans, "t", "topology"); hint(spans, "↑↓←→", "navigate"); if (diagram.findLinkedRouteId(drillDownRouteId) != null) { hint(spans, "Enter", "jump to route"); @@ -529,6 +563,7 @@ class DiagramTab implements MonitorTab { hint(spans, "c", "source"); } else if (!topologyMode) { hint(spans, "Esc", "back"); + hint(spans, "t", "topology"); hint(spans, "↑↓←→", "scroll"); hint(spans, "PgUp/PgDn", "page"); } else if (!diagram.getNodeBoxes().isEmpty()) { @@ -677,6 +712,7 @@ class DiagramTab implements MonitorTab { - `↑↓←→` — navigate between EIP nodes - `Enter` — jump to linked route (when `↵` indicator shown) - `Esc` — go back (previous route or topology) + - `t` — jump back to topology view **Common:** - `m` — toggle metrics on/off (default: on) @@ -700,16 +736,21 @@ class DiagramTab implements MonitorTab { return result; } - private String buildBreadcrumb() { + private Line buildBreadcrumbTitle() { + Style nameStyle = Style.EMPTY.fg(Color.YELLOW).bold(); + List<Span> spans = new ArrayList<>(); + spans.add(Span.raw(" Route [")); if (routeNavigationStack.isEmpty()) { - return drillDownRouteId; - } - StringBuilder sb = new StringBuilder(); - for (var it = routeNavigationStack.descendingIterator(); it.hasNext();) { - sb.append(it.next()).append(" → "); + spans.add(Span.styled(drillDownRouteId, nameStyle)); + } else { + for (var it = routeNavigationStack.descendingIterator(); it.hasNext();) { + spans.add(Span.styled(it.next(), nameStyle)); + spans.add(Span.raw(" → ")); + } + spans.add(Span.styled(drillDownRouteId, nameStyle)); } - sb.append(drillDownRouteId); - return sb.toString(); + spans.add(Span.raw("] ")); + return Line.from(spans); } private void loadSourceForSelectedNode() { 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 index 925b1549a08f..68f24106f2e5 100644 --- 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 @@ -19,7 +19,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui.diagram; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Set; +import java.util.Map; import dev.tamboui.buffer.Buffer; import dev.tamboui.layout.Rect; @@ -49,8 +49,6 @@ public class RouteDiagramWidget implements Widget { private static final char SCOPE_H = '╌'; private static final char SCOPE_V = '╎'; - private static final String LINK_INDICATOR = " ↵"; - private final LayoutRoute layoutRoute; private final int nodeWidth; private final int boxWidth; @@ -58,7 +56,7 @@ public class RouteDiagramWidget implements Widget { private final int scrollX; private final int scrollY; private final boolean showMetrics; - private final Set<String> linkableEndpoints; + private final Map<String, String> linkableEndpoints; private final List<EipNodeBox> nodeBoxes = new ArrayList<>(); @@ -70,13 +68,13 @@ public class RouteDiagramWidget implements Widget { LayoutRoute layoutRoute, int nodeWidth, int selectedNodeIndex, int scrollX, int scrollY, boolean showMetrics) { - this(layoutRoute, nodeWidth, selectedNodeIndex, scrollX, scrollY, showMetrics, Collections.emptySet()); + this(layoutRoute, nodeWidth, selectedNodeIndex, scrollX, scrollY, showMetrics, Collections.emptyMap()); } public RouteDiagramWidget( LayoutRoute layoutRoute, int nodeWidth, int selectedNodeIndex, int scrollX, int scrollY, - boolean showMetrics, Set<String> linkableEndpoints) { + boolean showMetrics, Map<String, String> linkableEndpoints) { this.layoutRoute = layoutRoute; this.nodeWidth = nodeWidth; this.boxWidth = Math.max(MIN_BOX_WIDTH, nodeWidth / X_DIVISOR); @@ -188,11 +186,12 @@ public class RouteDiagramWidget implements Widget { } // Link indicator for nodes that connect to other routes - if (isLinkable(node)) { + String linkedRouteId = findLinkedRouteId(node); + if (linkedRouteId != null) { Style linkStyle = selected ? Style.EMPTY.fg(Color.YELLOW).bold().patch(SELECTION_STYLE) : Style.EMPTY.fg(Color.YELLOW).bold(); - writeText(buffer, area, bottom, col + boxWidth, LINK_INDICATOR, linkStyle); + writeText(buffer, area, bottom, col + boxWidth, " ↵ " + linkedRouteId, linkStyle); } nodeBoxes.add(new EipNodeBox(node.id, node.type, row, row + height - 1, col, col + boxWidth - 1, node)); @@ -415,18 +414,18 @@ public class RouteDiagramWidget implements Widget { return pixelX * boxWidth / nodeWidth; } - private boolean isLinkable(LayoutNode node) { + private String findLinkedRouteId(LayoutNode node) { if (linkableEndpoints.isEmpty() || node.treeNode == null) { - return false; + return null; } String type = node.type; if (!"to".equals(type) && !"toD".equals(type) && !"wireTap".equals(type) && !"enrich".equals(type) && !"pollEnrich".equals(type) && !"from".equals(type)) { - return false; + return null; } String baseUri = getBaseUri(node.treeNode.info); - return baseUri != null && linkableEndpoints.contains(baseUri); + return baseUri != null ? linkableEndpoints.get(baseUri) : null; } private static String getBaseUri(RouteDiagramLayoutEngine.NodeInfo info) {
