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) {

Reply via email to