This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new b162df14e53b chore: camel-jbang - the Switch EIP drawn as a decision
table in the route diagram (#27228)
b162df14e53b is described below
commit b162df14e53bbfb93e8b994cb0b45a0c214e3ce7
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 19:45:26 2026 +0200
chore: camel-jbang - the Switch EIP drawn as a decision table in the route
diagram (#27228)
Co-Authored-By: Claude Opus 5.5 (1M context) <[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 ↓");
+ }
+}