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 b25da666b6f0 CAMEL-23485: camel-diagram - Add ASCII art and Unicode
text renderers (#23152)
b25da666b6f0 is described below
commit b25da666b6f0c9fa142c378ef4baf53425522d81
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed May 13 07:22:54 2026 +0200
CAMEL-23485: camel-diagram - Add ASCII art and Unicode text renderers
(#23152)
* CAMEL-23485: camel-diagram - Add ASCII art renderer
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* CAMEL-23485: camel-diagram - Update documentation for ASCII art renderer
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* CAMEL-23485: camel-diagram - Update documentation for ASCII art renderer
* CAMEL-23485: camel-diagram - Support --theme=ascii in route-diagram
command
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* CAMEL-23485: camel-diagram - Add scope boxes to ASCII art renderer
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* CAMEL-23485: camel-diagram - Add --theme=unicode with box-drawing
characters
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* CAMEL-23485: camel-diagram - Support ascii/unicode themes in
DiagramDevConsole
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* CAMEL-23485: Address review feedback
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
---
.../camel-diagram/src/main/docs/diagram.adoc | 123 ++++++-
.../camel/diagram/DefaultRouteDiagramDumper.java | 29 ++
.../apache/camel/diagram/DiagramDevConsole.java | 55 ++-
.../camel/diagram/RouteDiagramAsciiRenderer.java | 391 +++++++++++++++++++++
.../org/apache/camel/diagram/RouteDiagramTest.java | 206 +++++++++++
.../org/apache/camel/spi/RouteDiagramDumper.java | 32 ++
.../camel-jbang-cmd-route-diagram.adoc | 4 +-
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../commands/action/CamelRouteDiagramAction.java | 106 ++++--
9 files changed, 894 insertions(+), 54 deletions(-)
diff --git a/components/camel-diagram/src/main/docs/diagram.adoc
b/components/camel-diagram/src/main/docs/diagram.adoc
index d4517d27a0c8..87b765dedc59 100644
--- a/components/camel-diagram/src/main/docs/diagram.adoc
+++ b/components/camel-diagram/src/main/docs/diagram.adoc
@@ -10,14 +10,15 @@
*Since Camel {since}*
The Diagram module provides route diagram rendering capabilities for Apache
Camel routes.
-It can generate visual route diagrams as PNG images representations from route
structure data.
+It can generate visual route diagrams as PNG images or plain ASCII art text
representations from route structure data.
== Features
* Render route diagrams as PNG images with colored nodes and scope boxes
+* Render route diagrams as plain ASCII art text for terminal output
* Support for all Camel EIPs: choice, doTry/doCatch, filter, split, loop,
multicast, and more
* Scope boxes visually group branching and scoping EIPs
-* Multiple color themes: dark, light, transparent, or custom
+* Multiple color themes: dark, light, transparent, or custom (PNG only)
== Usage
@@ -35,7 +36,7 @@ Add the `camel-diagram` dependency to your project:
==== Using Camel Java API
-You can use the diagram render with Camel based APIs such as:
+You can use the diagram renderer with the Camel API to render as PNG images:
[source,java]
----
@@ -43,6 +44,14 @@ RouteDiagramDumper dumper =
PluginHelper.getRouteDiagramDumper(context);
BufferedImage image = dumper.dumpRoutesAsImage("*",
RouteDiagramDumper.Theme.DARK);
----
+Or render as ASCII art text:
+
+[source,java]
+----
+RouteDiagramDumper dumper = PluginHelper.getRouteDiagramDumper(context);
+String ascii = dumper.dumpRoutesAsAsciiArt("*");
+----
+
==== Using standalone Java API
Then use the API to render diagrams:
@@ -68,6 +77,25 @@ BufferedImage image = renderer.renderDiagram(List.of(lr),
lr.maxY + RouteDiagram
ImageIO.write(image, "PNG", new File("diagram.png"));
----
+To render as ASCII art instead:
+
+[source,java]
+----
+import org.apache.camel.diagram.*;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.*;
+
+// Parse route structure from JSON
+List<RouteInfo> routes = RouteDiagramHelper.parseRoutes(jsonObject);
+
+// Layout and render as ASCII
+RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+LayoutRoute lr = engine.layoutRoute(routes.get(0),
RouteDiagramLayoutEngine.PADDING);
+
+RouteDiagramAsciiRenderer renderer = new
RouteDiagramAsciiRenderer(engine.getNodeWidth());
+String ascii = renderer.renderDiagram(List.of(lr), lr.maxY +
RouteDiagramLayoutEngine.V_GAP);
+System.out.println(ascii);
+----
+
=== With Camel JBang
The diagram rendering is used by the `camel cmd route-diagram` command in
Camel JBang:
@@ -100,3 +128,92 @@ To use dark theme
----
camel cmd route-diagram MyRoute.java --theme=dark
----
+
+== ASCII Art Rendering
+
+The ASCII art renderer produces plain text diagrams using box-drawing
characters.
+This is useful for terminal output where images cannot be displayed.
+
+Nodes are drawn as boxes using `+`, `-`, and `|` characters, with arrows using
`|` and `v`.
+Branching EIPs (choice, multicast, etc.) produce L-shaped arrows with
horizontal connector lines.
+Long labels are automatically wrapped to fit within the box width.
+
+Example output for a simple route:
+
+[source,text]
+----
+route1
+ +----------------------+
+ | timer:tick |
+ +----------------------+
+ |
+ |
+ |
+ v
+ +----------------------+
+ | log:a |
+ +----------------------+
+----
+
+Example output for a branching route with choice:
+
+[source,text]
+----
+route1
+ +----------------------+
+ | timer:tick |
+ +----------------------+
+ |
+ v
+ +----------------------+
+ | choice() |
+ +----------------------+
+ |
+ +---------------+---------------+
+ v v
+ +----------------------+ +----------------------+
+ | when(...) | | otherwise() |
+ +----------------------+ +----------------------+
+ | |
+ v v
+ +----------------------+ +----------------------+
+ | log:a | | log:b |
+ +----------------------+ +----------------------+
+----
+
+Scope boxes (for filter, split, doTry, etc.) are rendered with dashed borders
using `:` for vertical
+and `- - -` for horizontal lines.
+
+Use `--theme=ascii` for plain ASCII art:
+
+[source,bash]
+----
+camel cmd route-diagram MyRoute.java --theme=ascii
+----
+
+== Unicode Rendering
+
+The `unicode` theme uses Unicode box-drawing characters for a cleaner look.
+Node boxes use `┌──┐ │ └──┘`, arrows use `│` and `▼`, and branch junctions use
`┴`.
+Scope boxes use `╌` (dashed horizontal) and `╎` (dashed vertical) with no
corners.
+
+[source,bash]
+----
+camel cmd route-diagram MyRoute.java --theme=unicode
+----
+
+Example output:
+
+[source,text]
+----
+route1
+┌──────────────────────┐
+│ timer:tick │
+└──────────────────────┘
+ │
+ │
+ ▼
+┌──────────────────────┐
+│ log:a │
+└──────────────────────┘
+----
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
index 364d579cf7dd..ba0d83311d38 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DefaultRouteDiagramDumper.java
@@ -116,6 +116,16 @@ public class DefaultRouteDiagramDumper extends
ServiceSupport implements CamelCo
return renderImage(routes, theme.name(), fontSize, nodeWidth,
nodeLabel.name(), metrics);
}
+ @Override
+ public String dumpRoutesAsAsciiArt(
+ String filter, RouteDiagramDumper.NodeLabelMode nodeLabel, int
nodeWidth, boolean unicode) {
+ DevConsole dc =
getCamelContext().getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
+ .resolveById("route-structure");
+ JsonObject root = (JsonObject) dc.call(DevConsole.MediaType.JSON,
Map.of("filter", filter));
+ var routes = RouteDiagramHelper.parseRoutes(root);
+ return renderAscii(routes, nodeWidth, nodeLabel.name(), unicode);
+ }
+
@Override
public String imageToBase64(BufferedImage image) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -152,4 +162,23 @@ public class DefaultRouteDiagramDumper extends
ServiceSupport implements CamelCo
return renderer.renderDiagram(layoutRoutes, currentY, colors);
}
+ private static String renderAscii(
+ List<RouteDiagramLayoutEngine.RouteInfo> routes, int nodeWidth,
String nodeLabel, boolean unicode) {
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
+ nodeWidth, RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE,
+
RouteDiagramLayoutEngine.NodeLabelMode.valueOf(nodeLabel.toUpperCase()));
+
+ List<RouteDiagramLayoutEngine.LayoutRoute> layoutRoutes = new
ArrayList<>();
+ int currentY = RouteDiagramLayoutEngine.PADDING;
+ for (RouteDiagramLayoutEngine.RouteInfo route : routes) {
+ RouteDiagramLayoutEngine.LayoutRoute lr =
engine.layoutRoute(route, currentY);
+ layoutRoutes.add(lr);
+ currentY = lr.maxY + RouteDiagramLayoutEngine.V_GAP;
+ }
+
+ RouteDiagramAsciiRenderer renderer = new RouteDiagramAsciiRenderer(
+ nodeWidth * RouteDiagramLayoutEngine.SCALE, unicode);
+ return renderer.renderDiagram(layoutRoutes, currentY);
+ }
+
}
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DiagramDevConsole.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DiagramDevConsole.java
index 74265961117b..e41c3b8cf15f 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/DiagramDevConsole.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/DiagramDevConsole.java
@@ -35,7 +35,7 @@ public class DiagramDevConsole extends AbstractDevConsole {
public static final String FILTER = "filter";
/**
- * Theme to use: dark or light
+ * Theme to use: dark, light, transparent, ascii, or unicode
*/
public static final String THEME = "theme";
@@ -84,18 +84,25 @@ public class DiagramDevConsole extends AbstractDevConsole {
try {
RouteDiagramDumper dumper =
PluginHelper.getRouteDiagramDumper(getCamelContext());
- BufferedImage image = dumper.dumpRoutesAsImage(filter,
RouteDiagramDumper.Theme.valueOf(theme.toUpperCase()),
- metric,
RouteDiagramDumper.NodeLabelMode.valueOf(nodeLabel.toUpperCase()), nodeWidth,
fontSize);
- String base64 = dumper.imageToBase64(image);
- // For HTML embedding:
- String html = String.format(
- " <body>\n <img src=\"data:image/png;base64,%s\"
alt=\"Route Diagram\">\n </body>\n",
- base64);
- if (refresh) {
- html = "<head><meta http-equiv=\"refresh\"
content=\"5\"></head>\n" + html;
+ if (isTextTheme(theme)) {
+ String text = dumper.dumpRoutesAsAsciiArt(filter,
+
RouteDiagramDumper.NodeLabelMode.valueOf(nodeLabel.toUpperCase()),
+ nodeWidth, isUnicodeTheme(theme));
+ sj.add(text);
+ } else {
+ BufferedImage image = dumper.dumpRoutesAsImage(filter,
+ RouteDiagramDumper.Theme.valueOf(theme.toUpperCase()),
+ metric,
RouteDiagramDumper.NodeLabelMode.valueOf(nodeLabel.toUpperCase()), nodeWidth,
fontSize);
+ String base64 = dumper.imageToBase64(image);
+ String html = String.format(
+ " <body>\n <img src=\"data:image/png;base64,%s\"
alt=\"Route Diagram\">\n </body>\n",
+ base64);
+ if (refresh) {
+ html = "<head><meta http-equiv=\"refresh\"
content=\"5\"></head>\n" + html;
+ }
+ html = "<html>\n" + html + "</html>\n";
+ sj.add(html);
}
- html = "<html>\n" + html + "</html>\n";
- sj.add(html);
} catch (Exception e) {
// ignore
}
@@ -117,10 +124,18 @@ public class DiagramDevConsole extends AbstractDevConsole
{
JsonObject root = new JsonObject();
try {
RouteDiagramDumper dumper =
PluginHelper.getRouteDiagramDumper(getCamelContext());
- BufferedImage image = dumper.dumpRoutesAsImage(filter,
RouteDiagramDumper.Theme.valueOf(theme.toUpperCase()),
- metric,
RouteDiagramDumper.NodeLabelMode.valueOf(nodeLabel.toUpperCase()), nodeWidth,
fontSize);
- String base64 = dumper.imageToBase64(image);
- root.put("image", base64);
+ if (isTextTheme(theme)) {
+ String text = dumper.dumpRoutesAsAsciiArt(filter,
+
RouteDiagramDumper.NodeLabelMode.valueOf(nodeLabel.toUpperCase()),
+ nodeWidth, isUnicodeTheme(theme));
+ root.put("text", text);
+ } else {
+ BufferedImage image = dumper.dumpRoutesAsImage(filter,
+ RouteDiagramDumper.Theme.valueOf(theme.toUpperCase()),
+ metric,
RouteDiagramDumper.NodeLabelMode.valueOf(nodeLabel.toUpperCase()), nodeWidth,
fontSize);
+ String base64 = dumper.imageToBase64(image);
+ root.put("image", base64);
+ }
} catch (Exception e) {
// ignore
}
@@ -128,4 +143,12 @@ public class DiagramDevConsole extends AbstractDevConsole {
return root;
}
+ private static boolean isTextTheme(String theme) {
+ return "ascii".equalsIgnoreCase(theme) ||
"unicode".equalsIgnoreCase(theme);
+ }
+
+ private static boolean isUnicodeTheme(String theme) {
+ return "unicode".equalsIgnoreCase(theme);
+ }
+
}
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
new file mode 100644
index 000000000000..853c08ec7cd4
--- /dev/null
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java
@@ -0,0 +1,391 @@
+/*
+ * 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.diagram;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.Bounds;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutNode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.TreeNode;
+
+import static org.apache.camel.diagram.RouteDiagramLayoutEngine.PADDING;
+import static org.apache.camel.diagram.RouteDiagramLayoutEngine.SCOPE_BOX_PAD;
+
+/**
+ * Renders route diagrams as plain ASCII art or Unicode box-drawing text.
+ */
+public class RouteDiagramAsciiRenderer {
+
+ private static final int MAX_WRAP_LINES = 3;
+ private static final int Y_SCALE = 20;
+ private static final int MIN_BOX_WIDTH = 16;
+ private static final int X_DIVISOR = 15;
+
+ // Unicode box-drawing characters
+ private static final char UNI_H = '─'; // ─
+ private static final char UNI_V = '│'; // │
+ private static final char UNI_TL = '┌'; // ┌
+ private static final char UNI_TR = '┐'; // ┐
+ private static final char UNI_BL = '└'; // └
+ private static final char UNI_BR = '┘'; // ┘
+ private static final char UNI_T_DOWN = '┬'; // ┬
+ private static final char UNI_T_UP = '┴'; // ┴
+ private static final char UNI_CROSS = '┼'; // ┼
+ private static final char UNI_ARROW = '▼'; // ▼
+ private static final char UNI_DASH_H = '╌'; // ╌
+ private static final char UNI_DASH_V = '╎'; // ╎
+
+ private final int nodeWidth;
+ private final int boxWidth;
+ private final boolean unicode;
+
+ public RouteDiagramAsciiRenderer(int nodeWidth) {
+ this(nodeWidth, false);
+ }
+
+ public RouteDiagramAsciiRenderer(int nodeWidth, boolean unicode) {
+ this.nodeWidth = nodeWidth;
+ this.boxWidth = Math.max(MIN_BOX_WIDTH, nodeWidth / X_DIVISOR);
+ this.unicode = unicode;
+ }
+
+ public int getBoxWidth() {
+ return boxWidth;
+ }
+
+ public String renderDiagram(List<LayoutRoute> layoutRoutes, int
totalHeight) {
+ int maxPixelX = layoutRoutes.stream()
+ .mapToInt(lr -> lr.maxX).max().orElse(nodeWidth) + PADDING;
+ int gridWidth = toCol(maxPixelX) + boxWidth + 4;
+ int gridHeight = totalHeight / Y_SCALE + 20;
+
+ char[][] grid = new char[gridHeight][gridWidth];
+ for (char[] row : grid) {
+ Arrays.fill(row, ' ');
+ }
+
+ for (LayoutRoute lr : layoutRoutes) {
+ drawRoute(grid, lr);
+ }
+
+ return gridToString(grid);
+ }
+
+ private void drawRoute(char[][] grid, LayoutRoute lr) {
+ int labelRow = toRow(lr.labelY);
+ String label = lr.routeId;
+ if (lr.source != null && !lr.source.isEmpty()) {
+ label += " (" + lr.source + ")";
+ }
+ drawText(grid, labelRow, toCol(PADDING), label);
+
+ for (LayoutNode ln : lr.nodes) {
+ if (ln.treeNode != null &&
RouteDiagramLayoutEngine.hasScope(ln.treeNode)) {
+ drawScopeBox(grid, ln);
+ }
+ }
+
+ for (LayoutNode ln : lr.nodes) {
+ if (ln.parentNode != null) {
+ if (ln.connectFromMerge) {
+ drawMergeArrow(grid, ln);
+ } else {
+ drawArrow(grid, ln.parentNode, ln);
+ }
+ }
+ }
+
+ for (LayoutNode ln : lr.nodes) {
+ drawNode(grid, ln);
+ }
+ }
+
+ private void drawNode(char[][] grid, LayoutNode node) {
+ int col = toCol(node.x);
+ int row = toRow(node.y);
+ int innerWidth = boxWidth - 4;
+ List<String> lines = rewrapText(node, innerWidth);
+ int height = 2 + lines.size();
+
+ if (row + height >= grid.length) {
+ return;
+ }
+
+ char h = unicode ? UNI_H : '-';
+ char v = unicode ? UNI_V : '|';
+
+ setChar(grid, row, col, unicode ? UNI_TL : '+');
+ for (int c = col + 1; c < col + boxWidth - 1; c++) {
+ setChar(grid, row, c, h);
+ }
+ setChar(grid, row, col + boxWidth - 1, unicode ? UNI_TR : '+');
+
+ int bottom = row + height - 1;
+ setChar(grid, bottom, col, unicode ? UNI_BL : '+');
+ for (int c = col + 1; c < col + boxWidth - 1; c++) {
+ setChar(grid, bottom, c, h);
+ }
+ setChar(grid, bottom, col + boxWidth - 1, unicode ? UNI_BR : '+');
+
+ for (int i = 0; i < lines.size(); i++) {
+ int r = row + 1 + i;
+ setChar(grid, r, col, v);
+ setChar(grid, r, col + boxWidth - 1, v);
+ for (int c = col + 1; c < col + boxWidth - 1; c++) {
+ setChar(grid, r, c, ' ');
+ }
+ String text = lines.get(i);
+ if (text.length() > innerWidth) {
+ text = text.substring(0, Math.max(1, innerWidth - 3)) + "...";
+ }
+ int textCol = col + 2 + Math.max(0, (innerWidth - text.length()) /
2);
+ drawText(grid, r, textCol, text);
+ }
+ }
+
+ private void drawArrow(char[][] grid, LayoutNode from, LayoutNode to) {
+ int fromCx = centerCol(from);
+ int fromBottom = toRow(from.y) + boxHeight(from);
+ int toCx = centerCol(to);
+ int toTop = getTopRow(to);
+
+ drawArrowPath(grid, fromCx, fromBottom, toCx, toTop);
+ }
+
+ private void drawMergeArrow(char[][] grid, LayoutNode to) {
+ int fromCx = toCol(to.mergeCx);
+ int fromRow = toRow(to.mergeY);
+ int toCx = centerCol(to);
+ int toTop = getTopRow(to);
+
+ drawArrowPath(grid, fromCx, fromRow, toCx, toTop);
+ }
+
+ private void drawArrowPath(char[][] grid, int fromCx, int fromRow, int
toCx, int toRow) {
+ if (fromRow >= toRow) {
+ return;
+ }
+
+ char v = unicode ? UNI_V : '|';
+ char h = unicode ? UNI_H : '-';
+ char arrow = unicode ? UNI_ARROW : 'v';
+
+ if (fromCx == toCx) {
+ for (int r = fromRow; r < toRow - 1; r++) {
+ plotLine(grid, r, fromCx, v);
+ }
+ setChar(grid, toRow - 1, toCx, arrow);
+ } else {
+ int midRow = fromRow + (toRow - fromRow) / 2;
+
+ for (int r = fromRow; r < midRow; r++) {
+ plotLine(grid, r, fromCx, v);
+ }
+
+ int minC = Math.min(fromCx, toCx);
+ int maxC = Math.max(fromCx, toCx);
+ for (int c = minC; c <= maxC; c++) {
+ plotLine(grid, midRow, c, h);
+ }
+
+ if (unicode) {
+ setChar(grid, midRow, fromCx, UNI_T_UP);
+ setChar(grid, midRow, toCx, UNI_T_DOWN);
+ } else {
+ setChar(grid, midRow, fromCx, '+');
+ setChar(grid, midRow, toCx, '+');
+ }
+
+ for (int r = midRow + 1; r < toRow - 1; r++) {
+ plotLine(grid, r, toCx, v);
+ }
+ setChar(grid, toRow - 1, toCx, arrow);
+ }
+ }
+
+ private void drawScopeBox(char[][] grid, LayoutNode scopeNode) {
+ TreeNode tn = scopeNode.treeNode;
+ Bounds bounds = new Bounds(
+ scopeNode.x, scopeNode.y,
+ scopeNode.x + nodeWidth, scopeNode.y + scopeNode.height);
+ for (TreeNode child : tn.children) {
+ RouteDiagramLayoutEngine.expandBoundsForBox(child, bounds,
nodeWidth);
+ }
+
+ int col1 = toCol(bounds.minX - SCOPE_BOX_PAD);
+ int row1 = toRow(bounds.minY - SCOPE_BOX_PAD);
+ int col2 = toCol(bounds.maxX + SCOPE_BOX_PAD);
+ int row2 = toRow(bounds.maxY + SCOPE_BOX_PAD);
+
+ if (unicode) {
+ for (int c = col1; c <= col2; c++) {
+ setChar(grid, row1, c, UNI_DASH_H);
+ setChar(grid, row2, c, UNI_DASH_H);
+ }
+ for (int r = row1 + 1; r < row2; r++) {
+ setChar(grid, r, col1, UNI_DASH_V);
+ setChar(grid, r, col2, UNI_DASH_V);
+ }
+ } else {
+ drawDashedHLine(grid, row1, col1, col2);
+ drawDashedHLine(grid, row2, col1, col2);
+ for (int r = row1 + 1; r < row2; r++) {
+ setChar(grid, r, col1, ':');
+ setChar(grid, r, col2, ':');
+ }
+ }
+ }
+
+ private void drawDashedHLine(char[][] grid, int row, int col1, int col2) {
+ for (int c = col1; c <= col2; c++) {
+ if (c == col1 || c == col2) {
+ setChar(grid, row, c, '+');
+ } else if ((c - col1) % 2 == 0) {
+ setChar(grid, row, c, '-');
+ }
+ }
+ }
+
+ private int getTopRow(LayoutNode node) {
+ if (node.treeNode != null &&
RouteDiagramLayoutEngine.hasScope(node.treeNode)) {
+ return toRow(node.y - SCOPE_BOX_PAD);
+ }
+ return toRow(node.y);
+ }
+
+ private int centerCol(LayoutNode node) {
+ return toCol(node.x + nodeWidth / 2);
+ }
+
+ private int boxHeight(LayoutNode node) {
+ return 2 + rewrapText(node, boxWidth - 4).size();
+ }
+
+ private List<String> rewrapText(LayoutNode node, int maxWidth) {
+ String label = String.join("", node.wrappedLines);
+ return wrapText(label, maxWidth);
+ }
+
+ static List<String> wrapText(String text, int maxWidth) {
+ if (maxWidth <= 0 || text.length() <= maxWidth) {
+ return List.of(text);
+ }
+
+ List<String> lines = new ArrayList<>();
+ String remaining = text;
+
+ while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
+ if (remaining.length() <= maxWidth) {
+ lines.add(remaining);
+ remaining = "";
+ break;
+ }
+
+ int breakAt = -1;
+ for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
+ char c = remaining.charAt(i);
+ if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ','
|| c == '&' || c == '?') {
+ breakAt = i + 1;
+ }
+ }
+ if (breakAt <= 0) {
+ breakAt = maxWidth;
+ }
+
+ lines.add(remaining.substring(0, breakAt).stripTrailing());
+ remaining = remaining.substring(breakAt).stripLeading();
+ }
+
+ if (!remaining.isEmpty()) {
+ int lastIdx = lines.size() - 1;
+ String lastLine = lines.get(lastIdx);
+ if (lastLine.length() + remaining.length() <= maxWidth) {
+ lines.set(lastIdx, lastLine + remaining);
+ } else {
+ String combined = lastLine + remaining;
+ lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth
- 3)) + "...");
+ }
+ }
+
+ return lines;
+ }
+
+ private int toCol(int pixelX) {
+ if (nodeWidth == 0) {
+ return 0;
+ }
+ return pixelX * boxWidth / nodeWidth;
+ }
+
+ private int toRow(int pixelY) {
+ return pixelY / Y_SCALE;
+ }
+
+ private void setChar(char[][] grid, int row, int col, char ch) {
+ if (row >= 0 && row < grid.length && col >= 0 && col < grid[0].length)
{
+ grid[row][col] = ch;
+ }
+ }
+
+ private char getChar(char[][] grid, int row, int col) {
+ if (row >= 0 && row < grid.length && col >= 0 && col < grid[0].length)
{
+ return grid[row][col];
+ }
+ return ' ';
+ }
+
+ private boolean isVertical(char ch) {
+ return ch == '|' || ch == UNI_V;
+ }
+
+ private boolean isHorizontal(char ch) {
+ return ch == '-' || ch == UNI_H;
+ }
+
+ private void plotLine(char[][] grid, int row, int col, char ch) {
+ char current = getChar(grid, row, col);
+ if ((isVertical(current) && isHorizontal(ch)) ||
(isHorizontal(current) && isVertical(ch))) {
+ setChar(grid, row, col, unicode ? UNI_CROSS : '+');
+ } else {
+ setChar(grid, row, col, ch);
+ }
+ }
+
+ private void drawText(char[][] grid, int row, int col, String text) {
+ for (int i = 0; i < text.length(); i++) {
+ setChar(grid, row, col + i, text.charAt(i));
+ }
+ }
+
+ private String gridToString(char[][] grid) {
+ int lastRow = 0;
+ for (int r = 0; r < grid.length; r++) {
+ if (!new String(grid[r]).isBlank()) {
+ lastRow = r;
+ }
+ }
+ StringBuilder sb = new StringBuilder();
+ for (int r = 0; r <= lastRow; r++) {
+ sb.append(new String(grid[r]).stripTrailing());
+ sb.append('\n');
+ }
+ return sb.toString();
+ }
+}
diff --git
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java
index 80e8f2359541..b6ab0d9fd7d8 100644
---
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java
+++
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java
@@ -852,6 +852,212 @@ class RouteDiagramTest {
assertNull(RouteDiagramHelper.extractSourceName(""));
}
+ @Test
+ void testAsciiDiagramSequential() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.nodes.add(node("from", "timer:tick", 0));
+ route.nodes.add(node("to", "log:a", 1));
+ route.nodes.add(node("to", "log:b", 1));
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route,
RouteDiagramLayoutEngine.PADDING);
+
+ RouteDiagramAsciiRenderer renderer = new
RouteDiagramAsciiRenderer(engine.getNodeWidth());
+ String result = renderer.renderDiagram(List.of(lr), lr.maxY +
RouteDiagramLayoutEngine.V_GAP);
+
+ assertTrue(result.contains("route1"));
+ assertTrue(result.contains("timer:tick"));
+ assertTrue(result.contains("log:a"));
+ assertTrue(result.contains("log:b"));
+ assertTrue(result.contains("+"));
+ assertTrue(result.contains("v"));
+ }
+
+ @Test
+ void testAsciiDiagramBranching() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.nodes.add(node("from", "timer:tick", 0));
+ route.nodes.add(node("choice", "choice()", 1));
+ route.nodes.add(node("when", "when(...)", 2));
+ route.nodes.add(node("to", "log:a", 3));
+ route.nodes.add(node("otherwise", "otherwise()", 2));
+ route.nodes.add(node("to", "log:b", 3));
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route,
RouteDiagramLayoutEngine.PADDING);
+
+ RouteDiagramAsciiRenderer renderer = new
RouteDiagramAsciiRenderer(engine.getNodeWidth());
+ String result = renderer.renderDiagram(List.of(lr), lr.maxY +
RouteDiagramLayoutEngine.V_GAP);
+
+ assertTrue(result.contains("route1"));
+ assertTrue(result.contains("timer:tick"));
+ assertTrue(result.contains("choice()"));
+ assertTrue(result.contains("when(...)"));
+ assertTrue(result.contains("otherwise()"));
+ assertTrue(result.contains("log:a"));
+ assertTrue(result.contains("log:b"));
+ // branching arrows use horizontal lines
+ assertTrue(result.contains("-"), "Branching should contain horizontal
arrow lines");
+ }
+
+ @Test
+ void testAsciiDiagramEmptyRoute() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "empty";
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route,
RouteDiagramLayoutEngine.PADDING);
+
+ RouteDiagramAsciiRenderer renderer = new
RouteDiagramAsciiRenderer(engine.getNodeWidth());
+ String result = renderer.renderDiagram(List.of(lr), lr.maxY +
RouteDiagramLayoutEngine.V_GAP);
+
+ assertTrue(result.contains("empty"));
+ }
+
+ @Test
+ void testAsciiDiagramWithSource() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.source = "test.yaml";
+ route.nodes.add(node("from", "timer:tick", 0));
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route,
RouteDiagramLayoutEngine.PADDING);
+
+ RouteDiagramAsciiRenderer renderer = new
RouteDiagramAsciiRenderer(engine.getNodeWidth());
+ String result = renderer.renderDiagram(List.of(lr), lr.maxY +
RouteDiagramLayoutEngine.V_GAP);
+
+ assertTrue(result.contains("route1 (test.yaml)"));
+ }
+
+ @Test
+ void testAsciiDiagramLongLabel() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.nodes.add(node("from",
"kafka:my-topic?brokers=localhost:9092&groupId=myGroup", 0));
+ route.nodes.add(node("to", "log:a", 1));
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route,
RouteDiagramLayoutEngine.PADDING);
+
+ RouteDiagramAsciiRenderer renderer = new
RouteDiagramAsciiRenderer(engine.getNodeWidth());
+ String result = renderer.renderDiagram(List.of(lr), lr.maxY +
RouteDiagramLayoutEngine.V_GAP);
+
+ assertTrue(result.contains("kafka:"));
+ assertTrue(result.contains("log:a"));
+ }
+
+ @Test
+ void testAsciiDiagramWithScopeBox() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.nodes.add(node("from", "direct:start", 0));
+ route.nodes.add(node("filter", "filter[header(x)]", 1));
+ route.nodes.add(node("log", "log[filtered]", 2));
+ route.nodes.add(node("to", "to[mock:end]", 1));
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route,
RouteDiagramLayoutEngine.PADDING);
+
+ RouteDiagramAsciiRenderer renderer = new
RouteDiagramAsciiRenderer(engine.getNodeWidth());
+ String result = renderer.renderDiagram(List.of(lr), lr.maxY +
RouteDiagramLayoutEngine.V_GAP);
+
+ assertTrue(result.contains("filter[header(x)]"));
+ assertTrue(result.contains("log[filtered]"));
+ assertTrue(result.contains("to[mock:end]"));
+ assertTrue(result.contains(":"), "Scope box should have dashed
vertical borders");
+ assertTrue(result.contains("+ -"), "Scope box should have dashed
horizontal borders");
+ }
+
+ @Test
+ void testUnicodeDiagramSequential() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.nodes.add(node("from", "timer:tick", 0));
+ route.nodes.add(node("to", "log:a", 1));
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route,
RouteDiagramLayoutEngine.PADDING);
+
+ RouteDiagramAsciiRenderer renderer = new
RouteDiagramAsciiRenderer(engine.getNodeWidth(), true);
+ String result = renderer.renderDiagram(List.of(lr), lr.maxY +
RouteDiagramLayoutEngine.V_GAP);
+
+ assertTrue(result.contains("timer:tick"));
+ assertTrue(result.contains("log:a"));
+ assertTrue(result.contains("┌"), "Should contain Unicode top-left
corner");
+ assertTrue(result.contains("─"), "Should contain Unicode horizontal
line");
+ assertTrue(result.contains("│"), "Should contain Unicode vertical
line");
+ assertTrue(result.contains("▼"), "Should contain Unicode arrow head");
+ }
+
+ @Test
+ void testUnicodeDiagramBranching() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.nodes.add(node("from", "timer:tick", 0));
+ route.nodes.add(node("choice", "choice()", 1));
+ route.nodes.add(node("when", "when(...)", 2));
+ route.nodes.add(node("to", "log:a", 3));
+ route.nodes.add(node("otherwise", "otherwise()", 2));
+ route.nodes.add(node("to", "log:b", 3));
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route,
RouteDiagramLayoutEngine.PADDING);
+
+ RouteDiagramAsciiRenderer renderer = new
RouteDiagramAsciiRenderer(engine.getNodeWidth(), true);
+ String result = renderer.renderDiagram(List.of(lr), lr.maxY +
RouteDiagramLayoutEngine.V_GAP);
+
+ assertTrue(result.contains("choice()"));
+ assertTrue(result.contains("when(...)"));
+ assertTrue(result.contains("┴"), "Should contain Unicode T-up junction
for branch split");
+ }
+
+ @Test
+ void testUnicodeDiagramWithScopeBox() {
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.nodes.add(node("from", "direct:start", 0));
+ route.nodes.add(node("filter", "filter[header(x)]", 1));
+ route.nodes.add(node("log", "log[filtered]", 2));
+ route.nodes.add(node("to", "to[mock:end]", 1));
+
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ LayoutRoute lr = engine.layoutRoute(route,
RouteDiagramLayoutEngine.PADDING);
+
+ RouteDiagramAsciiRenderer renderer = new
RouteDiagramAsciiRenderer(engine.getNodeWidth(), true);
+ String result = renderer.renderDiagram(List.of(lr), lr.maxY +
RouteDiagramLayoutEngine.V_GAP);
+
+ assertTrue(result.contains("filter[header(x)]"));
+ assertTrue(result.contains("╌"), "Scope box should have Unicode dashed
horizontal");
+ assertTrue(result.contains("╎"), "Scope box should have Unicode dashed
vertical");
+ }
+
+ @Test
+ void testAsciiWrapTextShort() {
+ List<String> lines = RouteDiagramAsciiRenderer.wrapText("timer:tick",
20);
+ assertEquals(1, lines.size());
+ assertEquals("timer:tick", lines.get(0));
+ }
+
+ @Test
+ void testAsciiWrapTextWrap() {
+ List<String> lines =
RouteDiagramAsciiRenderer.wrapText("kafka:my-topic?brokers=localhost:9092", 20);
+ assertTrue(lines.size() > 1, "Long text should wrap");
+ String rejoined = String.join("", lines);
+ assertTrue(rejoined.contains("kafka:"));
+ assertTrue(rejoined.contains("9092"));
+ }
+
+ @Test
+ void testAsciiWrapTextTruncate() {
+ String veryLong = "a]".repeat(60);
+ List<String> lines = RouteDiagramAsciiRenderer.wrapText(veryLong, 20);
+ assertTrue(lines.size() <= 3, "Should not exceed 3 lines");
+ assertTrue(lines.get(lines.size() - 1).endsWith("..."), "Truncated
text should end with ...");
+ }
+
private static NodeInfo node(String type, String code, int level) {
return node(type, code, level, null);
}
diff --git
a/core/camel-api/src/main/java/org/apache/camel/spi/RouteDiagramDumper.java
b/core/camel-api/src/main/java/org/apache/camel/spi/RouteDiagramDumper.java
index 7060cc5d0da2..1194e2e73f7d 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/RouteDiagramDumper.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/RouteDiagramDumper.java
@@ -89,4 +89,36 @@ public interface RouteDiagramDumper {
*/
String imageToBase64(BufferedImage image) throws IOException;
+ /**
+ * Dumps the routes as ASCII art text
+ *
+ * @param filter to filter routes
+ */
+ default String dumpRoutesAsAsciiArt(String filter) {
+ return dumpRoutesAsAsciiArt(filter, NodeLabelMode.CODE, 180, false);
+ }
+
+ /**
+ * Dumps the routes as ASCII art text
+ *
+ * @param filter to filter routes
+ * @param nodeLabel what information to display in the nodes
+ * @param nodeWidth the width in pixels of the node boxes
+ */
+ default String dumpRoutesAsAsciiArt(String filter, NodeLabelMode
nodeLabel, int nodeWidth) {
+ return dumpRoutesAsAsciiArt(filter, nodeLabel, nodeWidth, false);
+ }
+
+ /**
+ * Dumps the routes as ASCII art or Unicode box-drawing text
+ *
+ * @param filter to filter routes
+ * @param nodeLabel what information to display in the nodes
+ * @param nodeWidth the width in pixels of the node boxes
+ * @param unicode whether to use Unicode box-drawing characters
+ */
+ default String dumpRoutesAsAsciiArt(String filter, NodeLabelMode
nodeLabel, int nodeWidth, boolean unicode) {
+ throw new UnsupportedOperationException();
+ }
+
}
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
index d118b519b5c9..354db16e98f4 100644
---
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
+++
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
@@ -25,8 +25,8 @@ camel cmd route-diagram [options]
| `--ignore-loading-error` | Whether to ignore route loading and compilation
errors (use this with care!) | false | boolean
| `--metric` | Whether to include live metrics (only possible for running
Camel application) | true | boolean
| `--node-label` | What text to display in diagram nodes: code, description,
or both (default) | both | String
-| `--output` | Save diagram to a PNG file instead of displaying in terminal |
| String
-| `--theme` | Color theme preset (dark, light, transparent) or custom colors
(e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or ANSI color
names (e.g. from=seagreen:to=steelblue). Use bg= for transparent. Can also be
set via DIAGRAM_COLORS env var. | transparent | String
+| `--output` | Save diagram to a file (PNG for image themes, text for ascii
theme) | | String
+| `--theme` | Color theme preset (dark, light, transparent, ascii, unicode) or
custom colors (e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or
ANSI color names (e.g. from=seagreen:to=steelblue). Use bg= for transparent.
Use ascii/unicode for plain text output. Can also be set via DIAGRAM_COLORS env
var. | transparent | String
| `--watch` | Execute periodically and showing output fullscreen | | boolean
| `--width` | Image width in pixels (0 = auto) | 0 | int
| `-h,--help` | Display the help and sub-commands | | boolean
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index 9e7a209d62c0..399406bab646 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -2,7 +2,7 @@
"commands": [
{ "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind
source and sink Kamelets as a new Camel integration", "deprecated": true,
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options":
[ { "names": "--error-handler", "description": "Add error handler
(none|log|sink:<endpoint>). Sink endpoints are expected in the format
[[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet
name.", "javaType": "java.lang.String", "type": "stri [...]
{ "name": "catalog", "fullName": "catalog", "description": "List artifacts
from Camel Catalog", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [
{ "names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"component", "fullName": "catalog component", "description": "List components
from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
- { "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
+ { "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
{ "name": "completion", "fullName": "completion", "description": "Generate
completion script for bash\/zsh", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names":
"-h,--help", "description": "Display the help and sub-commands", "javaType":
"boolean", "type": "boolean" } ] },
{ "name": "config", "fullName": "config", "description": "Get and set user
configuration values", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get",
"fullName": "config get", "description": "Display user configuration value",
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...]
{ "name": "debug", "fullName": "debug", "description": "Debug local Camel
integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug",
"options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd
HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names":
"--background", "description": "Run in the background", "defaultValue":
"false", "javaType": "boolean", "type": "boolean" }, { "names":
"--background-wait", "description": "To [...]
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
index 165b53b1cd7e..cde562c5235a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
@@ -26,6 +26,7 @@ import java.util.List;
import javax.imageio.ImageIO;
+import org.apache.camel.diagram.RouteDiagramAsciiRenderer;
import org.apache.camel.diagram.RouteDiagramHelper;
import org.apache.camel.diagram.RouteDiagramLayoutEngine;
import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
@@ -68,14 +69,15 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
int width;
@CommandLine.Option(names = { "--output" },
- description = "Save diagram to a PNG file instead of
displaying in terminal")
+ description = "Save diagram to a file (PNG for image
themes, text for ascii theme)")
String output;
@CommandLine.Option(names = { "--theme" },
- description = "Color theme preset (dark, light,
transparent) or custom colors "
+ description = "Color theme preset (dark, light,
transparent, ascii, unicode) or custom colors "
+ "(e.g.
bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or "
+ "ANSI color names (e.g.
from=seagreen:to=steelblue). "
- + "Use bg= for transparent. Can also be
set via DIAGRAM_COLORS env var.",
+ + "Use bg= for transparent. Use
ascii/unicode for plain text output. "
+ + "Can also be set via DIAGRAM_COLORS
env var.",
defaultValue = "transparent")
String theme;
@@ -115,18 +117,26 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
public Integer doCall() throws Exception {
System.setProperty("java.awt.headless", "true");
- // if output in terminal then ensure terminal supports this
- if (output == null) {
+ boolean textMode = isTextTheme();
+ if (!textMode) {
String colorSpec = System.getenv("DIAGRAM_COLORS");
colors = DiagramColors.parse(colorSpec != null ? colorSpec :
theme);
+ }
+
+ // if output in terminal then set up terminal
+ if (output == null) {
terminal = TerminalBuilder.builder().system(true).build();
- terminalGraphics =
TerminalGraphicsManager.getBestProtocol(terminal).orElse(null);
- if (terminalGraphics == null) {
- printer().println("Terminal does not support graphics
protocols (Kitty, iTerm2, or Sixel).");
- printer().println("Try running in a supported terminal: Kitty,
iTerm2, WezTerm, Ghostty, or VS Code.");
- return 1;
- }
lineReader =
LineReaderBuilder.builder().terminal(terminal).build();
+ if (!textMode) {
+ terminalGraphics =
TerminalGraphicsManager.getBestProtocol(terminal).orElse(null);
+ if (terminalGraphics == null) {
+ printer().println("Terminal does not support graphics
protocols (Kitty, iTerm2, or Sixel).");
+ printer().println(
+ "Try running in a supported terminal: Kitty,
iTerm2, WezTerm, Ghostty, or VS Code.");
+ printer().println("Or use --theme=ascii or --theme=unicode
for plain text output.");
+ return 1;
+ }
+ }
}
try {
@@ -190,9 +200,6 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
NodeLabelMode labelMode = parseNodeLabelMode(nodeLabel);
RouteDiagramLayoutEngine engine = new
RouteDiagramLayoutEngine(boxWidth, fontSize, labelMode);
- RouteDiagramRenderer renderer = new RouteDiagramRenderer(
- engine.getNodeWidth(), fontSize *
RouteDiagramLayoutEngine.SCALE, engine.getNodeTextPadding(),
- pid > 0 && metric);
List<LayoutRoute> layoutRoutes = new ArrayList<>();
int currentY = RouteDiagramLayoutEngine.PADDING;
@@ -202,27 +209,54 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
currentY = lr.maxY + RouteDiagramLayoutEngine.V_GAP;
}
- BufferedImage image;
- try {
- image = renderer.renderDiagram(layoutRoutes, currentY, colors);
- } catch (IllegalStateException e) {
- printer().println(e.getMessage());
- return 1;
- }
-
- if (output != null) {
- File file = new File(output);
- File parentDir = file.getParentFile();
- if (parentDir != null) {
- parentDir.mkdirs();
+ if (isTextTheme()) {
+ RouteDiagramAsciiRenderer asciiRenderer
+ = new RouteDiagramAsciiRenderer(engine.getNodeWidth(),
isUnicodeTheme());
+ String ascii = asciiRenderer.renderDiagram(layoutRoutes,
currentY);
+
+ if (output != null) {
+ String fileName = output.endsWith(".png")
+ ? output.substring(0, output.length() - 4) +
".txt" : output;
+ File file = new File(fileName);
+ File parentDir = file.getParentFile();
+ if (parentDir != null) {
+ parentDir.mkdirs();
+ }
+ Files.writeString(file.toPath(), ascii);
+ printer().println("Diagram saved to: " +
file.getAbsolutePath());
+ } else {
+ if (watch) {
+ clearScreen();
+ }
+ printer().println(ascii);
}
- ImageIO.write(image, "PNG", file);
- printer().println("Diagram saved to: " +
file.getAbsolutePath());
} else {
- if (watch) {
- clearScreen();
+ RouteDiagramRenderer renderer = new RouteDiagramRenderer(
+ engine.getNodeWidth(), fontSize *
RouteDiagramLayoutEngine.SCALE, engine.getNodeTextPadding(),
+ pid > 0 && metric);
+
+ BufferedImage image;
+ try {
+ image = renderer.renderDiagram(layoutRoutes, currentY,
colors);
+ } catch (IllegalStateException e) {
+ printer().println(e.getMessage());
+ return 1;
+ }
+
+ if (output != null) {
+ File file = new File(output);
+ File parentDir = file.getParentFile();
+ if (parentDir != null) {
+ parentDir.mkdirs();
+ }
+ ImageIO.write(image, "PNG", file);
+ printer().println("Diagram saved to: " +
file.getAbsolutePath());
+ } else {
+ if (watch) {
+ clearScreen();
+ }
+ doDisplayDiagram(image);
}
- doDisplayDiagram(image);
}
return 0;
@@ -319,6 +353,14 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
return RouteDiagramHelper.parseRoutes(jo);
}
+ private boolean isTextTheme() {
+ return "ascii".equalsIgnoreCase(theme) ||
"unicode".equalsIgnoreCase(theme);
+ }
+
+ private boolean isUnicodeTheme() {
+ return "unicode".equalsIgnoreCase(theme);
+ }
+
static NodeLabelMode parseNodeLabelMode(String value) {
if (value == null || value.isBlank() ||
"code".equalsIgnoreCase(value)) {
return NodeLabelMode.CODE;