This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch quick-fix/switch-decision-table
in repository https://gitbox.apache.org/repos/asf/camel.git

commit c308d49d1e4d951d888200268a667106075dc99e
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 18:30:25 2026 +0200

    chore: camel-jbang - the Switch EIP drawn as a decision table in the route 
diagram
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../camel/diagram/RouteDiagramLayoutEngine.java    |  84 +++++++++-
 .../diagram/RouteDiagramLayoutEngineTest.java      |  62 +++++++
 .../jbang/core/commands/tui/DiagramSupport.java    |   9 +-
 .../core/commands/tui/diagram/DiagramColors.java   |   2 +-
 .../commands/tui/diagram/RouteDiagramWidget.java   | 182 ++++++++++++++++++++-
 .../core/commands/tui/diagram/SwitchTableTest.java | 115 +++++++++++++
 6 files changed, 443 insertions(+), 11 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 3a6e8298247a..c03320cd89ec 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
@@ -53,6 +53,7 @@ public class RouteDiagramLayoutEngine {
     private final int baseNodeHeight;
     private final FontMetrics fontMetrics;
     private final NodeLabelMode nodeLabelMode;
+    private boolean tableLayout;
 
     static final Set<String> BRANCHING_EIPS = Set.of(
             "choice", "multicast", "doTry", "loadBalance", "recipientList", 
"circuitBreaker");
@@ -63,6 +64,15 @@ public class RouteDiagramLayoutEngine {
     static final Set<String> STRUCTURAL_TYPES = Set.of(
             "route", "from");
 
+    /**
+     * The EIPs drawn as a table, one row per child, with the table layout on: 
a Switch is a decision table of value and
+     * endpoint, its cases are alternatives (not steps after each other) and 
can be many.
+     */
+    public static final Set<String> TABLE_EIPS = Set.of("switch");
+
+    /** The rows of a table shown at once; the rest are reached by scrolling 
the table. */
+    public static final int MAX_TABLE_ROWS = 8;
+
     public static class Bounds {
         public int minX, minY, maxX, maxY;
 
@@ -119,6 +129,19 @@ public class RouteDiagramLayoutEngine {
         return nodeWidth;
     }
 
+    /**
+     * Draws the {@link #TABLE_EIPS} as one wide node with a row per child 
instead of a chain of boxes. Off by default:
+     * a renderer turns it on when it draws the rows ({@link 
LayoutNode#tableRow}).
+     */
+    public void setTableLayout(boolean tableLayout) {
+        this.tableLayout = tableLayout;
+    }
+
+    /** The width of a table node: two common nodes and the gap between them. 
*/
+    public int getTableWidth() {
+        return nodeWidth * 2 + hGap;
+    }
+
     public int getBaseNodeHeight() {
         return baseNodeHeight;
     }
@@ -178,6 +201,8 @@ public class RouteDiagramLayoutEngine {
         public final List<TreeNode> children = new ArrayList<>();
         public int subtreeWidth;
         public LayoutNode layoutNode;
+        /** Drawn as a table of its children (the table layout is on and it is 
one of {@link #TABLE_EIPS}). */
+        public boolean table;
 
         public TreeNode(NodeInfo info) {
             this.info = info;
@@ -196,6 +221,11 @@ public class RouteDiagramLayoutEngine {
         public boolean connectFromMerge;
         public int mergeY;
         public int mergeCx;
+        /** The width of the node when it is wider than the others (a table); 
0 for the common width. */
+        public int width;
+        /** A row of a table node: drawn inside its parent's box, at its index 
among the rows. */
+        public boolean tableRow;
+        public int tableRowIndex;
     }
 
     public static class LayoutRoute {
@@ -349,6 +379,9 @@ public class RouteDiagramLayoutEngine {
         lr.labelY = startY;
 
         TreeNode tree = buildTree(route.nodes);
+        if (tree != null && tableLayout) {
+            markTables(tree);
+        }
         if (tree == null) {
             lr.maxX = PADDING + nodeWidth;
             lr.maxY = startY + LABEL_OFFSET;
@@ -382,7 +415,18 @@ public class RouteDiagramLayoutEngine {
         return lr;
     }
 
+    private static void markTables(TreeNode node) {
+        node.table = TABLE_EIPS.contains(node.info.type) && 
!node.children.isEmpty();
+        for (TreeNode child : node.children) {
+            markTables(child);
+        }
+    }
+
     private int computeSubtreeWidth(TreeNode node) {
+        if (node.table) {
+            node.subtreeWidth = getTableWidth();
+            return node.subtreeWidth;
+        }
         if (node.children.isEmpty()) {
             node.subtreeWidth = nodeWidth;
             return node.subtreeWidth;
@@ -409,7 +453,9 @@ public class RouteDiagramLayoutEngine {
 
     private void assignPositions(TreeNode node, int x, int y, int parentWidth, 
LayoutRoute lr) {
         int availableWidth = Math.max(node.subtreeWidth, parentWidth);
-        int nodeX = x + (availableWidth - nodeWidth) / 2;
+        int ownWidth = node.table ? getTableWidth() : nodeWidth;
+        // a table stands centered where a common node would, so the arrows in 
and out stay straight
+        int nodeX = x + (availableWidth - nodeWidth) / 2 - (ownWidth - 
nodeWidth) / 2;
 
         LayoutNode ln = new LayoutNode();
         ln.type = node.info.type;
@@ -452,9 +498,30 @@ public class RouteDiagramLayoutEngine {
             }
         }
 
+        if (node.table) {
+            ln.width = ownWidth;
+            int rows = Math.min(node.children.size(), MAX_TABLE_ROWS)
+                       + (node.children.size() > MAX_TABLE_ROWS ? 1 : 0);
+            ln.height = Math.max(ln.height, baseNodeHeight + (rows - 1) * 
fontMetrics.getHeight());
+            for (int i = 0; i < node.children.size(); i++) {
+                TreeNode child = node.children.get(i);
+                LayoutNode row = new LayoutNode();
+                row.type = child.info.type;
+                row.id = child.info.id;
+                row.x = nodeX;
+                row.y = y;
+                row.wrappedLines = resolveLabel(child.info, nodeLabelMode);
+                row.treeNode = child;
+                row.tableRow = true;
+                row.tableRowIndex = i;
+                child.layoutNode = row;
+                lr.nodes.add(row);
+            }
+        }
+
         lr.maxY = Math.max(lr.maxY, y + ln.height);
 
-        if (node.children.isEmpty()) {
+        if (node.children.isEmpty() || node.table) {
             return;
         }
 
@@ -492,7 +559,7 @@ public class RouteDiagramLayoutEngine {
     }
 
     private static LayoutNode findLastLayoutNode(TreeNode node) {
-        if (node.children.isEmpty()) {
+        if (node.children.isEmpty() || node.table) {
             return node.layoutNode;
         }
         if (isBranchingEip(node.info.type)) {
@@ -503,6 +570,9 @@ public class RouteDiagramLayoutEngine {
 
     private static int findMaxY(TreeNode node) {
         int maxY = node.layoutNode != null ? node.layoutNode.y + 
node.layoutNode.height : 0;
+        if (node.table) {
+            return maxY;
+        }
         for (TreeNode child : node.children) {
             maxY = Math.max(maxY, findMaxY(child));
         }
@@ -516,6 +586,7 @@ public class RouteDiagramLayoutEngine {
     public static boolean hasScope(TreeNode node) {
         return node.parent != null
                 && !node.children.isEmpty()
+                && !node.table
                 && !BRANCH_CHILD_TYPES.contains(node.info.type)
                 && !STRUCTURAL_TYPES.contains(node.info.type);
     }
@@ -536,11 +607,16 @@ public class RouteDiagramLayoutEngine {
             bounds.maxY = Math.max(bounds.maxY, inner.maxY + SCOPE_BOX_PAD);
         } else {
             if (node.layoutNode != null) {
+                int w = node.layoutNode.width > 0 ? node.layoutNode.width : 
nodeWidth;
                 bounds.minX = Math.min(bounds.minX, node.layoutNode.x);
                 bounds.minY = Math.min(bounds.minY, node.layoutNode.y);
-                bounds.maxX = Math.max(bounds.maxX, node.layoutNode.x + 
nodeWidth);
+                bounds.maxX = Math.max(bounds.maxX, node.layoutNode.x + w);
                 bounds.maxY = Math.max(bounds.maxY, node.layoutNode.y + 
node.layoutNode.height);
             }
+            if (node.table) {
+                // the rows are inside the table's box
+                return;
+            }
             for (TreeNode child : node.children) {
                 expandBoundsForBox(child, bounds, nodeWidth);
             }
diff --git 
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java
 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java
index c8a569a079f4..e6470930cd49 100644
--- 
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java
+++ 
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java
@@ -231,4 +231,66 @@ class RouteDiagramLayoutEngineTest {
                 .as("maxY must equal the bottom of the deepest node")
                 .isEqualTo(l1.y + l1.height);
     }
+
+    // ─── Switch as a decision table (table layout) ──────────────────────────
+
+    private static RouteDiagramLayoutEngine.RouteInfo switchRoute(int cases) {
+        RouteDiagramLayoutEngine.RouteInfo r = route(
+                node("route", "r", 0),
+                node("from", "f", 1),
+                node("switch", "s", 2));
+        for (int i = 1; i <= cases; i++) {
+            r.nodes.add(node("case", "case" + i, 3));
+        }
+        r.nodes.add(node("to", "s-otherwise", 3));
+        r.nodes.add(node("log", "after", 2));
+        return r;
+    }
+
+    @Test
+    void withoutTheTableLayoutASwitchIsAChain() {
+        RouteDiagramLayoutEngine.LayoutRoute lr = 
ENGINE.layoutRoute(switchRoute(4), 0);
+        assertThat(findNode(lr, "case2").y).isGreaterThan(findNode(lr, 
"case1").y);
+        assertThat(findNode(lr, "case1").tableRow).isFalse();
+    }
+
+    @Test
+    void aSwitchIsOneWideTableWithARowPerCase() {
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        engine.setTableLayout(true);
+        RouteDiagramLayoutEngine.LayoutRoute lr = 
engine.layoutRoute(switchRoute(4), 0);
+
+        RouteDiagramLayoutEngine.LayoutNode table = findNode(lr, "s");
+        assertThat(table.width).isEqualTo(engine.getTableWidth());
+        assertThat(table.treeNode.table).isTrue();
+        // the rows: inside the table, in order, without arrows of their own
+        for (int i = 1; i <= 4; i++) {
+            RouteDiagramLayoutEngine.LayoutNode row = findNode(lr, "case" + i);
+            assertThat(row.tableRow).isTrue();
+            assertThat(row.tableRowIndex).isEqualTo(i - 1);
+            assertThat(row.parentNode).isNull();
+            assertThat(row.y).isEqualTo(table.y);
+        }
+        assertThat(findNode(lr, "s-otherwise").tableRowIndex).isEqualTo(4);
+        // the next step comes after the table, from the table
+        RouteDiagramLayoutEngine.LayoutNode after = findNode(lr, "after");
+        assertThat(after.parentNode).isSameAs(table);
+        assertThat(after.y).isGreaterThanOrEqualTo(table.y + table.height);
+        // centered where a common node is: the arrows stay straight
+        RouteDiagramLayoutEngine.LayoutNode from = findNode(lr, "f");
+        assertThat(table.x + table.width / 2).isEqualTo(from.x + NODE_W / 2);
+        assertThat(RouteDiagramLayoutEngine.hasScope(table.treeNode)).as("no 
scope box around a table").isFalse();
+    }
+
+    @Test
+    void aLongTableShowsTheFirstRowsAndSaysThereAreMore() {
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        engine.setTableLayout(true);
+        RouteDiagramLayoutEngine.LayoutNode small = 
findNode(engine.layoutRoute(switchRoute(4), 0), "s");
+        RouteDiagramLayoutEngine.LayoutNode big = 
findNode(engine.layoutRoute(switchRoute(30), 0), "s");
+        RouteDiagramLayoutEngine.LayoutNode bigger = 
findNode(engine.layoutRoute(switchRoute(60), 0), "s");
+        assertThat(big.height).isGreaterThan(small.height);
+        assertThat(bigger.height).as("as tall as " + 
RouteDiagramLayoutEngine.MAX_TABLE_ROWS + " rows and the more line")
+                .isEqualTo(big.height);
+    }
 }
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 593643b3c40c..388262078be2 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
@@ -1153,7 +1153,7 @@ class DiagramSupport {
             return null;
         }
         // Only "to"-style nodes can link to other routes
-        if (!"to".equals(type) && !"toD".equals(type) && 
!"wireTap".equals(type)
+        if (!"to".equals(type) && !"toD".equals(type) && !"case".equals(type) 
&& !"wireTap".equals(type)
                 && !"enrich".equals(type) && !"pollEnrich".equals(type)
                 && !"from".equals(type)) {
             return null;
@@ -1206,7 +1206,7 @@ class DiagramSupport {
     private static boolean sendsTo(RouteDiagramLayoutEngine.LayoutRoute lr, 
String baseUri) {
         for (var node : lr.nodes) {
             if (node.treeNode != null && ("to".equals(node.type) || 
"toD".equals(node.type)
-                    || "wireTap".equals(node.type) || 
"enrich".equals(node.type))
+                    || "case".equals(node.type) || "wireTap".equals(node.type) 
|| "enrich".equals(node.type))
                     && baseUri.equals(getBaseUri(node.treeNode.info))) {
                 return true;
             }
@@ -1412,7 +1412,7 @@ class DiagramSupport {
             if (currentFromUri != null) {
                 for (var node : lr.nodes) {
                     String type = node.type;
-                    if (("to".equals(type) || "toD".equals(type) || 
"wireTap".equals(type))
+                    if (("to".equals(type) || "toD".equals(type) || 
"case".equals(type) || "wireTap".equals(type))
                             && node.treeNode != null) {
                         String uri = getBaseUri(node.treeNode.info);
                         if (currentFromUri.equals(uri)) {
@@ -1646,6 +1646,8 @@ class DiagramSupport {
         RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
                 RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, 
RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE,
                 labelMode);
+        // a Switch is drawn as a decision table, a row per case
+        engine.setTableLayout(true);
 
         Map<String, RouteDiagramLayoutEngine.LayoutRoute> routeMap = new 
LinkedHashMap<>();
         for (RouteDiagramLayoutEngine.RouteInfo r : routes) {
@@ -2185,6 +2187,7 @@ class DiagramSupport {
                 RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
                         RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, 
RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE,
                         labelMode);
+                engine.setTableLayout(true);
                 for (RouteDiagramLayoutEngine.RouteInfo r : routes) {
                     RouteDiagramLayoutEngine.LayoutRoute lr = 
engine.layoutRoute(r, 0);
                     normalizeRouteLayoutY(lr);
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 ffa37460d7c8..86416c408201 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
@@ -94,7 +94,7 @@ public final class DiagramColors {
         return switch (type) {
             case "from" -> Theme.diagramFrom();
             case "to", "toD", "wireTap", "enrich", "pollEnrich" -> 
Theme.diagramTo();
-            case "choice", "when", "otherwise" -> Theme.diagramChoice();
+            case "choice", "when", "otherwise", "switch", "case" -> 
Theme.diagramChoice();
             case "marshal", "unmarshal", "transform", "setBody", "setHeader", 
"setProperty",
                     "convertBodyTo", "removeHeader", "removeHeaders", 
"removeProperty", "removeProperties" ->
                 Theme.diagramTo();
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 39387c557f30..ff92bbd4f79d 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
@@ -172,7 +172,13 @@ public class RouteDiagramWidget implements Widget {
         // Nodes (on top, skip the structural "route" node)
         markers.clear();
         for (LayoutNode ln : layoutRoute.nodes) {
-            if (!"route".equals(ln.type)) {
+            if (ln.tableRow) {
+                // drawn by its table
+                continue;
+            }
+            if (ln.treeNode != null && ln.treeNode.table) {
+                drawTable(buffer, area, ln);
+            } else if (!"route".equals(ln.type)) {
                 drawNode(buffer, area, ln);
             }
         }
@@ -488,13 +494,183 @@ public class RouteDiagramWidget implements Widget {
     }
 
     private int centerCol(LayoutNode node) {
-        return toCol(node.x + nodeWidth / 2);
+        return toCol(node.x + (node.width > 0 ? node.width : nodeWidth) / 2);
     }
 
     private int boxHeight(LayoutNode node) {
+        if (node.treeNode != null && node.treeNode.table) {
+            return 2 + tableVisibleRows(node.treeNode.children.size());
+        }
         return 2 + rewrapText(node, boxWidth - 4).size();
     }
 
+    /** The rows a table shows: its cases up to the limit, and a line that 
says there are more. */
+    private static int tableVisibleRows(int rows) {
+        return Math.min(rows, RouteDiagramLayoutEngine.MAX_TABLE_ROWS)
+               + (rows > RouteDiagramLayoutEngine.MAX_TABLE_ROWS ? 1 : 0);
+    }
+
+    /**
+     * A Switch as a decision table: the selector in the top border, one row 
per case (the value, where it sends, its
+     * count) and the otherwise. Its rows are nodes of their own (selected, 
highlighted, linked), drawn inside the box;
+     * a long table shows a window of its rows that follows the selection.
+     */
+    private void drawTable(Buffer buffer, Rect area, LayoutNode table) {
+        List<TreeNode> rows = table.treeNode.children;
+        int col = toCol(table.x);
+        int width = Math.max(boxWidth * 2, toCol(table.x + table.width) - col);
+        int window = Math.min(rows.size(), 
RouteDiagramLayoutEngine.MAX_TABLE_ROWS);
+        boolean more = rows.size() > window;
+        int row = toRow(table.y);
+        int height = 2 + window + (more ? 1 : 0);
+
+        int tableIdx = nodeBoxes.size();
+        boolean selected = tableIdx == selectedNodeIndex;
+        int selectedRow = selectedNodeIndex - tableIdx - 1;
+        int offset = selectedRow >= window ? Math.min(selectedRow - window + 
1, rows.size() - window) : 0;
+
+        Color eipColor = getEipColor(table.type);
+        Style borderStyle = Style.EMPTY.fg(eipColor);
+        if (selected) {
+            borderStyle = borderStyle.patch(selectionStyle());
+        }
+        int right = col + width - 1;
+        int bottom = row + height - 1;
+        setChar(buffer, area, row, col, TL, borderStyle);
+        setChar(buffer, area, row, right, TR, borderStyle);
+        setChar(buffer, area, bottom, col, BL, borderStyle);
+        setChar(buffer, area, bottom, right, BR, borderStyle);
+        for (int c = col + 1; c < right; c++) {
+            setChar(buffer, area, row, c, H, borderStyle);
+            setChar(buffer, area, bottom, c, H, borderStyle);
+        }
+        for (int r = row + 1; r < bottom; r++) {
+            setChar(buffer, area, r, col, V, borderStyle);
+            setChar(buffer, area, r, right, V, borderStyle);
+            for (int c = col + 1; c < right; c++) {
+                setChar(buffer, area, r, c, ' ', Style.EMPTY);
+            }
+        }
+        String header = " " + tableHeader(table) + " ";
+        if (header.length() > width - 4) {
+            header = header.substring(0, Math.max(1, width - 5)) + "… ";
+        }
+        writeText(buffer, area, row, col + 2, header, 
style(Style.EMPTY.fg(eipColor).bold(), selected));
+        // the table itself: its top border, so a click on a row selects the 
row
+        nodeBoxes.add(new EipNodeBox(table.id, table.type, row, row, col, 
right, table));
+
+        int inner = width - 4;
+        int valueWidth = 4;
+        for (TreeNode r : rows) {
+            valueWidth = Math.max(valueWidth, caseValue(r).length());
+        }
+        valueWidth = Math.min(valueWidth, Math.max(4, inner / 3));
+        for (int i = 0; i < rows.size(); i++) {
+            LayoutNode rowNode = rows.get(i).layoutNode;
+            int visible = i - offset;
+            if (visible < 0 || visible >= window) {
+                // out of the window: still a node to select, the window moves 
to it
+                int at = visible < 0 ? row : bottom;
+                nodeBoxes.add(new EipNodeBox(rowNode.id, rowNode.type, at, at, 
col, right, rowNode));
+                continue;
+            }
+            int r = row + 1 + visible;
+            int idx = nodeBoxes.size();
+            boolean rowSelected = idx == selectedNodeIndex;
+            boolean highlighted = rowNode.id != null && 
highlightNodeIds.contains(rowNode.id);
+            StatInfo stat = showMetrics ? rows.get(i).info.stat : null;
+            long total = stat != null ? stat.exchangesTotal : 0;
+
+            String count = stat != null ? String.valueOf(total) : "";
+            String link = findLinkedRouteId(rowNode) != null ? "↵ " : "";
+            String target = withoutOptions(caseTarget(rows.get(i)));
+            String value = caseValue(rows.get(i));
+            if (value.length() > valueWidth) {
+                value = value.substring(0, Math.max(1, valueWidth - 1)) + "…";
+            }
+            String tail = link + count;
+            int targetRoom = Math.max(1, inner - valueWidth - 3 - 
tail.length() - 1);
+            if (target.length() > targetRoom) {
+                target = target.substring(0, Math.max(1, targetRoom - 1)) + 
"…";
+            }
+
+            Style base = rowSelected ? selectionStyle() : Style.EMPTY;
+            for (int c = col + 1; c < right; c++) {
+                setChar(buffer, area, r, c, ' ', base);
+            }
+            Style valueStyle = highlighted
+                    ? Style.EMPTY.fg(highlightFailed ? highlightFailColor() : 
highlightOkColor()).bold()
+                    : Style.EMPTY.fg(eipColor);
+            // idle when its counter says so; a row without one (no 
statistics) is not dimmed
+            boolean idle = stat != null && total == 0 && !highlighted;
+            Style targetStyle = idle ? Theme.muted() : fromLabelStyle();
+            writeText(buffer, area, r, col + 2, String.format("%-" + 
valueWidth + "s", value),
+                    style(valueStyle, rowSelected));
+            writeText(buffer, area, r, col + 2 + valueWidth, " → ", 
style(Theme.muted(), rowSelected));
+            writeText(buffer, area, r, col + 5 + valueWidth, target, 
style(targetStyle, rowSelected));
+            if (!tail.isEmpty()) {
+                writeText(buffer, area, r, right - 1 - tail.length(), tail,
+                        style(link.isEmpty() ? metricsOkStyle() : 
Theme.label().bold(), rowSelected));
+            }
+            nodeBoxes.add(new EipNodeBox(rowNode.id, rowNode.type, r, r, col, 
right, rowNode));
+        }
+        if (more) {
+            String note = (offset > 0 ? "↑ " : "") + rows.size() + " cases, " 
+ (offset + 1) + "-" + (offset + window)
+                          + " shown" + (offset + window < rows.size() ? " ↓" : 
"");
+            writeText(buffer, area, bottom - 1, col + 2, note, Theme.muted());
+        }
+    }
+
+    /** The selector of a Switch, as its header: switch 
exchangeProperty{specialist}. */
+    private String tableHeader(LayoutNode table) {
+        var info = table.treeNode.info;
+        if (showDescription && info.description != null && 
!info.description.isBlank()) {
+            return info.description;
+        }
+        String code = info.code != null ? info.code : table.type;
+        int open = code.indexOf('[');
+        int close = code.lastIndexOf(']');
+        return open > 0 && close > open ? code.substring(0, open) + "  " + 
code.substring(open + 1, close) : code;
+    }
+
+    /** The value of a case row (case[reservation -> a2a:...]), or otherwise. 
*/
+    static String caseValue(TreeNode row) {
+        String code = row.info.code != null ? row.info.code : "";
+        if (code.startsWith("otherwise")) {
+            return "otherwise";
+        }
+        int open = code.indexOf('[');
+        int arrow = code.indexOf(" -> ");
+        if (open >= 0 && arrow > open) {
+            return code.substring(open + 1, arrow);
+        }
+        return row.info.id != null ? row.info.id : "";
+    }
+
+    /** An endpoint without its options, which a table row has no room for: 
a2a:{{agents.weather.url}}. */
+    static String withoutOptions(String uri) {
+        if (uri == null) {
+            return "";
+        }
+        int q = uri.indexOf('?');
+        return q >= 0 ? uri.substring(0, q) : uri;
+    }
+
+    /** Where a case row sends to. */
+    static String caseTarget(TreeNode row) {
+        if (row.info.uri != null) {
+            return row.info.uri;
+        }
+        String code = row.info.code != null ? row.info.code : "";
+        int arrow = code.indexOf(" -> ");
+        int open = code.indexOf('[');
+        int close = code.lastIndexOf(']');
+        if (arrow > 0 && close > arrow) {
+            return code.substring(arrow + 4, close);
+        }
+        return open >= 0 && close > open ? code.substring(open + 1, close) : 
code;
+    }
+
     private List<String> rewrapText(LayoutNode node, int maxWidth) {
         String label = String.join("", node.wrappedLines);
         if (showDescription) {
@@ -610,7 +786,7 @@ public class RouteDiagramWidget implements Widget {
             return null;
         }
         String type = node.type;
-        if (!"to".equals(type) && !"toD".equals(type) && 
!"wireTap".equals(type)
+        if (!"to".equals(type) && !"toD".equals(type) && !"case".equals(type) 
&& !"wireTap".equals(type)
                 && !"enrich".equals(type) && !"pollEnrich".equals(type)
                 && !"from".equals(type)) {
             return null;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/SwitchTableTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/SwitchTableTest.java
new file mode 100644
index 000000000000..7922dfcb1062
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/SwitchTableTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.List;
+import java.util.Map;
+import java.util.Set;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.StatInfo;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A Switch is drawn as a decision table: one box, a row per case with where 
it sends and its own count.
+ */
+class SwitchTableTest {
+
+    private static NodeInfo node(String type, String id, int level, String 
code, String uri, Long total) {
+        NodeInfo n = new NodeInfo();
+        n.type = type;
+        n.id = id;
+        n.level = level;
+        n.code = code;
+        n.uri = uri;
+        if (total != null) {
+            n.stat = new StatInfo();
+            n.stat.exchangesTotal = total;
+        }
+        return n;
+    }
+
+    private static RouteInfo route(int cases) {
+        RouteInfo r = new RouteInfo();
+        r.routeId = "specialist-dispatch";
+        r.nodes.add(node("route", "specialist-dispatch", 0, 
"route[specialist-dispatch]", null, null));
+        r.nodes.add(node("from", "specialist-dispatch", 1, 
"from[direct:dispatch]", "direct:dispatch", 9L));
+        r.nodes.add(node("switch", "switch1", 2, 
"switch[exchangeProperty{specialist}]", null, 9L));
+        String[] names = { "reservation", "weather", "cost", "general" };
+        for (int i = 0; i < cases; i++) {
+            String value = i < names.length ? names[i] : "value" + i;
+            String uri = "a2a:{{agents." + value + 
".url}}?protocolBinding=JSONRPC";
+            r.nodes.add(node("case", "case" + (i + 1), 3, "case[" + value + " 
-> " + uri + "]", uri, (long) i + 1));
+        }
+        r.nodes.add(node("to", "switch1-otherwise", 3, 
"otherwise[direct:unsupported]", "direct:unsupported", 2L));
+        return r;
+    }
+
+    private static List<String> render(RouteInfo route, int selected) {
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        engine.setTableLayout(true);
+        LayoutRoute lr = engine.layoutRoute(route, 0);
+        RouteDiagramWidget widget = new RouteDiagramWidget(
+                lr, engine.getNodeWidth(), selected, 0, 0, true,
+                Map.of("direct:unsupported", "unsupported"), false, Map.of(), 
Set.of(), false);
+        Rect area = new Rect(0, 0, 120, 60);
+        Buffer buffer = Buffer.empty(area);
+        widget.render(area, buffer);
+        List<String> rows = new java.util.ArrayList<>();
+        for (int y = 0; y < area.height(); y++) {
+            StringBuilder sb = new StringBuilder();
+            for (int x = 0; x < area.width(); x++) {
+                sb.append(buffer.get(x, y).symbol());
+            }
+            rows.add(sb.toString().stripTrailing());
+        }
+        return rows;
+    }
+
+    @Test
+    void aRowPerCaseWithItsCount() {
+        List<String> rows = render(route(4), -1);
+        String all = String.join("\n", rows);
+
+        assertThat(all).contains("switch  exchangeProperty{specialist}");
+        assertThat(rows).anyMatch(r -> r.contains("reservation") && 
r.contains("→ a2a:{{agents.reservation.url}}")
+                && r.stripTrailing().endsWith("1 │"));
+        assertThat(rows).anyMatch(r -> r.contains("general") && 
r.stripTrailing().endsWith("4 │"));
+        // the otherwise sends to a route of the integration: a jump marker 
before its count
+        assertThat(rows).anyMatch(r -> r.contains("otherwise") && 
r.contains("↵ 2"));
+        // the options of an endpoint are left out, and the cases are not 
boxes of their own
+        
assertThat(all).doesNotContain("protocolBinding").doesNotContain("case[");
+    }
+
+    @Test
+    void aLongTableShowsAWindowThatFollowsTheSelection() {
+        // the boxes: from (0), the table (1), then its rows: row 20 is box 2 
+ 20
+        List<String> rows = render(route(30), 2 + 20);
+        String all = String.join("\n", rows);
+        assertThat(all).contains("value20").doesNotContain("reservation 
").doesNotContain("value21 ");
+        assertThat(all).contains("↑ 31 cases, 14-21 shown ↓");
+        // without a selection in it, the table shows its first rows
+        assertThat(String.join("\n", render(route(30), 
-1))).contains("reservation").contains("31 cases, 1-8 shown ↓");
+    }
+}

Reply via email to